diff --git a/.gitattributes b/.gitattributes index bf0e1f766d96..79c65323dade 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,7 +14,6 @@ contributing/ export-ignore .editorconfig export-ignore .nojekyll export-ignore export-ignore CODE_OF_CONDUCT.md export-ignore -DCO.txt export-ignore PULL_REQUEST_TEMPLATE.md export-ignore stale.yml export-ignore Vagrantfile.dist export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 02224130008f..000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: 'Bug: ' -labels: bug -assignees: '' - ---- - ---- -name: Bug report -about: Help us improve the framework by reporting bugs! - ---- - -**Direction** -We use github issues to track bugs, not for support. -If you have a support question, or a feature request, raise these as threads on our -[forum](https://forum.codeigniter.com/index.php). - -**Describe the bug** -A clear and concise description of what the bug is. - -**CodeIgniter 4 version** -Which version (and branch, if applicable) the bug is in. - -**Affected module(s)** -Which package or class is the bug in, if known. - -**Expected behavior, and steps to reproduce if appropriate** -A clear and concise description of what you expected to happen, -and how you got there. -Feel free to include a text/log extract, but use a pastebin facility for any -screenshots you deem necessary. - -**Context** - - OS: [e.g. Windows 99] - - Web server [e.g. Apache 1.2.3] - - PHP version [e.g. 6.5.4] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000000..135bd52b3fe9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,105 @@ +name: Bug report +description: Create a report to help us improve CodeIgniter +title: "Bug: " +labels: ['bug'] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + Before you begin, **please ensure that there are no existing issues, + whether still open or closed, related to your report**. + If there is, your report will be closed promptly. + + And if you are not using the [latest version](https://github.com/codeigniter4/CodeIgniter4/releases) of CodeIgniter, please update. + + --- + + - type: dropdown + id: php-version + attributes: + label: PHP Version + description: Which PHP versions did you run your code? + multiple: true + options: + - '7.3' + - '7.4' + - '8.0' + - '8.1' + validations: + required: true + + - type: input + id: codeigniter-version + attributes: + label: CodeIgniter4 Version + validations: + required: true + + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems have you tested for this bug? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + validations: + required: true + + - type: dropdown + id: server + attributes: + label: Which server did you use? + options: + - apache + - cli + - cli-server (PHP built-in webserver) + - cgi-fcgi + - fpm-fcgi + - phpdbg + validations: + required: true + + - type: input + id: database + attributes: + label: Database + validations: + required: false + + - type: textarea + id: description + attributes: + label: What happened? + placeholder: Tell us what you see! + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + validations: + required: true + + - type: textarea + attributes: + label: Expected Output + description: What do you expect to happen instead of this filed bug? + validations: + required: true + + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..69bb11b44fe8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false +contact_links: + - name: CodeIgniter Forum + url: https://forum.codeigniter.com + about: Please ask your support questions in the forums. Thanks! + + - name: CodeIgniter Slack channel + url: https://codeigniterchat.slack.com + about: Engage with other members of the community in our Slack channel. diff --git a/.github/ISSUE_TEMPLATE/support-question.md b/.github/ISSUE_TEMPLATE/support-question.md deleted file mode 100644 index 390991ac99d0..000000000000 --- a/.github/ISSUE_TEMPLATE/support-question.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: Support question -about: How to ask a support question -title: '' -labels: '' -assignees: '' - ---- - -Please ask support questions on our [forum](https://forum.codeigniter.com/forum-30.html). -We use github issues to track bugs and planned work. diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 87% rename from PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md index db5aeb9eca37..5b48f145ee3a 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,12 +5,12 @@ Explain what you have changed, and why. **Checklist:** - [ ] Securely signed commits -- [ ] Component(s) with PHPdocs +- [ ] Component(s) with PHPDoc blocks, only if necessary or adds value - [ ] Unit testing, with >80% coverage - [ ] User guide updated - [ ] Conforms to style guide ----------Remove from here down in your description---------- + diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 3b441e36485d..4a7d20dd502b 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -39,14 +39,12 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.0' + tools: phive coverage: none - - name: Download phpDocumentor v3.1 - run: | - cd ./source - curl \ - -L https://github.com/phpDocumentor/phpDocumentor/releases/download/v3.1.0/phpDocumentor.phar \ - -o admin/phpDocumentor.phar + - name: Download latest phpDocumentor + working-directory: source + run: sudo phive --no-progress install --global --trust-gpg-keys 67F861C3D889C656 phpDocumentor - name: Prepare API repo working-directory: api @@ -58,7 +56,7 @@ jobs: - name: Build API in source repo working-directory: source run: | - php admin/phpDocumentor.phar run --ansi --verbose + phpDocumentor run --ansi --verbose cp -R ${GITHUB_WORKSPACE}/source/api/build/* ${GITHUB_WORKSPACE}/api/docs - name: Deploy to API repo diff --git a/.github/workflows/deploy-framework.yml b/.github/workflows/deploy-framework.yml index 226cf242ed59..5a65f3bd1cfc 100644 --- a/.github/workflows/deploy-framework.yml +++ b/.github/workflows/deploy-framework.yml @@ -36,7 +36,7 @@ jobs: run: ./source/.github/scripts/deploy-framework ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/framework ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@v4 + uses: actions/github-script@v5 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | @@ -81,7 +81,7 @@ jobs: run: ./source/.github/scripts/deploy-appstarter ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/appstarter ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@v4 + uses: actions/github-script@v5 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml new file mode 100644 index 000000000000..0698809319fe --- /dev/null +++ b/.github/workflows/test-deptrac.yml @@ -0,0 +1,75 @@ +# When a PR is opened or a push is made, perform an +# architectural inspection on the code using Deptrac. +name: Deptrac + +on: + pull_request: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**' + - 'system/**' + - 'composer.json' + - 'depfile.yaml' + - '.github/workflows/test-deptrac.yml' + push: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**' + - 'system/**' + - 'composer.json' + - 'depfile.yaml' + - '.github/workflows/test-deptrac.yml' + +jobs: + build: + name: Architectural Inspection + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + tools: composer, phive + extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Create composer cache directory + run: mkdir -p ${{ steps.composer-cache.outputs.dir }} + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Create Deptrac cache directory + run: mkdir -p build/ + + - name: Cache Deptrac results + uses: actions/cache@v2 + with: + path: build + key: ${{ runner.os }}-deptrac-${{ github.sha }} + restore-keys: ${{ runner.os }}-deptrac- + + - name: Install dependencies + run: composer update --ansi --no-interaction + + - name: Run architectural inspection + run: | + sudo phive --no-progress install --global qossmic/deptrac --trust-gpg-keys B8F640134AB1782E + deptrac analyze --cache-file=build/deptrac.cache diff --git a/.github/workflows/test-phpcpd.yml b/.github/workflows/test-phpcpd.yml index fcc66f7d682c..f4215df01c0e 100644 --- a/.github/workflows/test-phpcpd.yml +++ b/.github/workflows/test-phpcpd.yml @@ -34,10 +34,8 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.0' - tools: phive - extensions: intl, json, mbstring, xml + tools: phpcpd + extensions: dom, mbstring - name: Detect code duplication - run: | - sudo phive --no-progress install --global --trust-gpg-keys 4AA394086372C20A phpcpd - phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php app/ public/ system/ + run: phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php --exclude system/Database/SQLSRV/Forge.php -- app/ public/ system/ diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index c093190233cb..aa7d584991a6 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -124,7 +124,6 @@ jobs: run: | composer update --ansi --no-interaction composer remove --ansi --dev --unused -W rector/rector phpstan/phpstan friendsofphp/php-cs-fixer nexusphp/cs-config codeigniter/coding-standard - php -r 'file_put_contents("vendor/laminas/laminas-zendframework-bridge/src/autoload.php", "");' env: COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index a1a1a25508a3..44cfa52c8b17 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -6,18 +6,22 @@ name: Test User Guide on: pull_request: - branches: - - 'develop' - - '4.*' paths: - 'user_guide_src/**' + - '.github/workflows/test-userguide.yml' jobs: syntax_check: name: Check User Guide syntax runs-on: ubuntu-20.04 + steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v2 + + - name: Detect usage of tabs in RST files + run: php utils/check_tabs_in_rst.php + - uses: ammaraskar/sphinx-action@0.4 with: docs-folder: user_guide_src diff --git a/.no-header.php-cs-fixer.dist.php b/.no-header.php-cs-fixer.dist.php index 12acddb81087..9417a859e07e 100644 --- a/.no-header.php-cs-fixer.dist.php +++ b/.no-header.php-cs-fixer.dist.php @@ -25,9 +25,22 @@ __DIR__ . '/app', __DIR__ . '/public', ]) - ->notName('#Logger\.php$#'); + ->notName('#Logger\.php$#') + ->append([ + __DIR__ . '/admin/starter/builds', + ]); -$overrides = []; +$overrides = [ + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'constant', + 'property', + 'method', + ], + 'sort_algorithm' => 'none', + ], +]; $options = [ 'cacheFile' => 'build/.no-header.php-cs-fixer.cache', diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3c9e05fe3b74..5df3c5e90a52 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -34,7 +34,17 @@ __DIR__ . '/spark', ]); -$overrides = []; +$overrides = [ + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'constant', + 'property', + 'method', + ], + 'sort_algorithm' => 'none', + ], +]; $options = [ 'cacheFile' => 'build/.php-cs-fixer.cache', diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 600d42940dfa..3688517a265a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,150 +1,6 @@ # Contributing to CodeIgniter4 -## Contributions +CodeIgniter is a community driven project and accepts contributions of +code and documentation from the community. -We expect all contributions to conform to our -[style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/styleguide.rst), -be commented (inside the PHP source files), be documented (in the -[user guide](https://codeigniter4.github.io/userguide/)), and unit tested (in -the [test folder](https://github.com/codeigniter4/CodeIgniter4/tree/develop/tests)). -There is a [Contributing to CodeIgniter](./contributing/README.rst) section in the repository which describes the contribution process; this page is an overview. - -Note, we expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite -to prove the code works. If pull requests are not accompanied by relevant tests, they will likely be closed. -Since we are a team of volunteers, we don't have any more time to work on the framework than you do. Please -make it as painless for your contributions to be included as possible. If you need help with getting tests -running on your local machines, ask for help on the forums. We would be happy to help out. - -The [Open Source Guide](https://opensource.guide/) is a good first read for those new to contributing to open source! -## Issues - -Issues are a quick way to point out a bug. If you find a bug or documentation error in CodeIgniter then please make sure that: - -1. There is not already an open [Issue](https://github.com/codeigniter4/CodeIgniter4/issues) -2. The Issue has not already been fixed (check the develop branch or look for [closed Issues](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aissue+is%3Aclosed)) -3. It's not something really obvious that you can fix yourself - -Reporting Issues is helpful, but an even [better approach](./contributing/workflow.rst) is to send a -[Pull Request](https://help.github.com/en/articles/creating-a-pull-request), which is done by -[Forking](https://help.github.com/en/articles/fork-a-repo) the main repository and making -a [Commit](https://help.github.com/en/desktop/contributing-to-projects/committing-and-reviewing-changes-to-your-project) -to your own copy of the project. This will require you to use the version control system called [Git](https://git-scm.com/). - -## Guidelines - -Before we look into how to contribute to CodeIgniter4, here are some guidelines. If your Pull Requests fail -to pass these guidelines, they will be declined, and you will need to re-submit when you’ve made the changes. -This might sound a bit tough, but it is required for us to maintain the quality of the codebase. - -### PHP Style - -All code must meet the [Style Guide](./contributing/styleguide.rst). -This makes certain that all submitted code is of the same format as the existing code and ensures that the codebase will be as readable as possible. - -### Documentation - -If you change anything that requires a change to documentation, then you will need to add to the documentation. New classes, methods, parameters, changing default values, etc. are all changes that require a change to documentation. Also, the [changelog](https://codeigniter4.github.io/CodeIgniter4/changelogs/index.html) must be updated for every change, and [PHPDoc](https://github.com/codeigniter4/CodeIgniter4/blob/develop/phpdoc.dist.xml) blocks must be maintained. - -### Compatibility - -CodeIgniter4 requires [PHP 7.3](https://php.net/releases/7_3_0.php). - -### Branching - -CodeIgniter4 uses the [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) branching model -which requires all Pull Requests to be sent to the __"develop"__ branch; this is where the next planned version will be developed. - -The __"master"__ branch will always contain the latest stable version and is kept clean so a "hotfix" (e.g. an -emergency security patch) can be applied to the "master" branch to create a new version, without worrying -about other features holding it up. For this reason, all commits need to be made to the "develop" branch, -and any sent to the "master" branch will be closed automatically. If you have multiple changes to submit, -please place all changes into their own branch on your fork. - -**One thing at a time:** A pull request should only contain one change. That does not mean only one commit, -but one change - however many commits it took. The reason for this is that if you change X and Y, -but send a pull request for both at the same time, we might really want X but disagree with Y, -meaning we cannot merge the request. Using the Git-Flow branching model you can create new -branches for both of these features and send two requests. - -A reminder: **please use separate branches for each of your PRs** - it will make it easier for you to keep -changes separate from each other and from whatever else you are doing with your repository! - -### Signing - -You must [GPG-sign](./contributing/signing.rst) your work, certifying that you either wrote the work or -otherwise have the right to pass it on to an open-source project. This is *not* just a "signed-off-by" -commit, but instead, a digitally signed one. - -### Static Analysis on PHP code - -We cannot, at all times, guarantee that all PHP code submitted on pull requests to be working well without -actually running the code. For this reason, we make use of two static analysis tools, [PHPStan][1] -and [Rector][2] to do the analysis for us. - -These tools have already been integrated into our CI/CD workflow to minimize unannounced bugs. Pull requests -are expected that their code will pass these two. In your local machine, you can manually run these tools -so that you can fix whatever errors that pop up with your submission. - -PHPStan is expected to scan the entire framework by running this command in your terminal: - - vendor/bin/phpstan analyse - -Rector, on the other hand, can be run on the specific files you modified or added: - - vendor/bin/rector process --dry-run path/to/file - -[1]: https://github.com/phpstan/phpstan-src -[2]: https://github.com/rector/rector - -### Breaking Changes - -In general, any change that would disrupt existing uses of the framework is considered a "breaking change" and will not be favorably considered. A few specific examples to pay attention to: - -1. New classes/properties/constants in `system` are acceptable, but anything in the `app` directory that will be used in `system` should be backwards-compatible. -2. Any changes to non-private methods must be backwards-compatible with the original definition. -3. Deleting non-private properties or methods without prior deprecation notices is frowned upon and will likely be closed. -4. Deleting or renaming public classes and interfaces, as well as those not marked as `@internal`, without prior deprecation notices or not providing fallback solutions will also not be favorably considered. - -## How-to Guide - -The best way to contribute is to fork the CodeIgniter4 repository, and "clone" that to your development area. That sounds like some jargon, but "forking" on GitHub means "making a copy of that repo to your account" and "cloning" means "copying that code to your environment so you can work on it". - -1. Set up Git ([Windows](https://git-scm.com/download/win), [Mac](https://git-scm.com/download/mac), & [Linux](https://git-scm.com/download/linux)). -2. Go to the [CodeIgniter4 repository](https://github.com/codeigniter4/CodeIgniter4). -3. [Fork](https://help.github.com/en/articles/fork-a-repo) it (to your Github account). -4. [Clone](https://help.github.com/en/articles/cloning-a-repository) your CodeIgniter repository: `git@github.com:\/CodeIgniter4.git` -5. Create a new [branch](https://help.github.com/en/articles/about-branches) in your project for each set of changes you want to make. -6. Fix existing bugs on the [Issue tracker](https://github.com/codeigniter4/CodeIgniter4/issues) after confirming that no one else is working on them. -7. [Commit](https://help.github.com/en/desktop/contributing-to-projects/committing-and-reviewing-changes-to-your-project) the changed files in your contribution branch. -8. Commit messages are expected to be descriptive of what you changed specifically. Commit messages like -"Fixes #1234" would be asked by the reviewer to be revised. -9. If there are intermediate commits that are not meaningful to the overall PR, such as "Fixed error on style guide", "Fixed phpstan error", "Fixing mistake in code", and other related commits, it is advised to squash your commits so that we can have a clean commit history. -10. If you have touched PHP code, run static analysis. -11. Run unit tests on the specific file you modified. If there are no existing tests yet, please create one. -12. Make sure the tests pass to have a higher chance of merging. -13. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork. -14. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). - -The codebase maintainers will now be alerted to the submission and someone from the team will respond. If your change fails to meet the guidelines, it will be rejected or feedback will be provided to help you improve it. - -Once the maintainer handling your pull request is satisfied with it they will approve the pull request and merge it into the "develop" branch. Your patch will now be part of the next release! - -### Keeping your fork up-to-date - -Unlike systems like Subversion, Git can have multiple remotes. A remote is the name for the URL of a Git repository. By default, your fork will have a remote named "origin", which points to your fork, but you can add another remote named "codeigniter", which points to `git://github.com/codeigniter4/CodeIgniter4.git`. This is a read-only remote, but you can pull from this develop branch to update your own. - -If you are using the command-line, you can do the following to update your fork to the latest changes: - -1. `git remote add codeigniter git://github.com/codeigniter4/CodeIgniter4.git` -2. `git pull codeigniter develop` -3. `git push origin develop` - -Your fork is now up to date. This should be done regularly and, at the least, before you submit a pull request. - -## Translations Installation - -If you wish to contribute to the system message translations, -then fork and clone the [translations repository](https://github.com/codeigniter4/translations) -separately from the codebase. - -These are two independent repositories! +If you'd like to contribute, please read the [Contributing to CodeIgniter](./contributing/README.md). diff --git a/DCO.txt b/DCO.txt deleted file mode 100644 index a404c0d38b0d..000000000000 --- a/DCO.txt +++ /dev/null @@ -1,25 +0,0 @@ -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(1) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(2) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(3) The contribution was provided directly to me by some other - person who certified (1), (2) or (3) and I have not modified - it. - -(4) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. diff --git a/README.md b/README.md index 5d9d007b91e2..fa5194457cbc 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ CodeIgniter is developed completely on a volunteer basis. As such, please give u for your issues to be reviewed. If you haven't heard from one of the team in that time period, feel free to leave a comment on the issue so that it gets brought back to our attention. -We use Github issues to track **BUGS** and to track approved **DEVELOPMENT** work packages. +We use GitHub issues to track **BUGS** and to track approved **DEVELOPMENT** work packages. We use our [forum](http://forum.codeigniter.com) to provide SUPPORT and to discuss FEATURE REQUESTS. @@ -54,7 +54,7 @@ If you raise an issue here that pertains to support or a feature request, it wil be closed! If you are not sure if you have found a bug, raise a thread on the forum first - someone else may have encountered the same thing. -Before raising a new Github issue, please check that your bug hasn't already +Before raising a new GitHub issue, please check that your bug hasn't already been reported or fixed. We use pull requests (PRs) for CONTRIBUTIONS to the repository. @@ -71,13 +71,7 @@ to optional packages, with their own repository. We **are** accepting contributions from the community! -We will try to manage the process somewhat, by adding a ["help wanted" label](https://github.com/codeigniter4/CodeIgniter4/labels/help%20wanted) to those that we are -specifically interested in at any point in time. Join the discussion for those issues and let us know -if you want to take the lead on one of them. - -At this time, we are not looking for out-of-scope contributions, only those that would be considered part of our controlled evolution! - -Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/CodeIgniter4/blob/develop/CONTRIBUTING.md) section in the user guide. +Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/README.md). ## Server Requirements diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..7879188492b5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +The development team and community take all security issues seriously. **Please do not make public any uncovered flaws.** + +## Reporting a Vulnerability + +Thank you for improving the security of our code! Any assistance in removing security flaws will be acknowledged. + +**Please report security flaws by emailing the development team directly: security@codeigniter.com**. + +The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating +the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the +progress towards a fix and full announcement, and may ask for additional information or guidance. + +## Disclosure Policy + +When the security team receives a security bug report, they will assign it to a primary handler. +This person will coordinate the fix and release process, involving the following steps: + +- Confirm the problem and determine the affected versions. +- Audit code to find any potential similar problems. +- Prepare fixes for all releases still under maintenance. These fixes will be released as fast as possible. + +## Comments on this Policy + +If you have suggestions on how this process could be improved please submit a Pull Request. diff --git a/admin/README.md b/admin/README.md index 52747ca3eeaa..1f5dd89bfcd1 100644 --- a/admin/README.md +++ b/admin/README.md @@ -9,7 +9,6 @@ This folder contains tools or docs useful for project maintainers. In addition to the framework source, it includes unit testing and documentation source. The three repositories following are built from this one as part of the release workflow. This repo is meant to be forked by contributors. - - **framework** is the released developer repository. It contains all the main pieces of the framework that developers would use to build their apps, but not the framework unit testing or the user guide source. @@ -25,9 +24,8 @@ This folder contains tools or docs useful for project maintainers. framework releases. It could be downloaded, forked or potentially composer-installed. This is a read-only repository. - -- **coding-standard** is the coding style standards repository. - It contains PHP CodeSniffer rules to ensure consistent code style +- **coding-standard** is the coding style standards repository. + It contains PHP-CS-Fixer rules to ensure consistent code style within the framework itself. It is meant to be composer-installed. - **translations** is the repository holding official translations of diff --git a/admin/css/debug-toolbar/_graphic-charter.scss b/admin/css/debug-toolbar/_graphic-charter.scss index 9e88e7177551..522f07f6e255 100644 --- a/admin/css/debug-toolbar/_graphic-charter.scss +++ b/admin/css/debug-toolbar/_graphic-charter.scss @@ -2,19 +2,19 @@ // ========================================================================== */ // Themes -$t-dark: #252525; +$t-dark: #252525; $t-light: #FFFFFF; // Glossy colors -$g-blue: #5BC0DE; -$g-gray: #434343; -$g-green: #9ACE25; +$g-blue: #5BC0DE; +$g-gray: #434343; +$g-green: #9ACE25; $g-orange: #DD8615; -$g-red: #DD4814; +$g-red: #DD4814; // Matt colors -$m-blue: #D8EAF0; -$m-gray: #DFDFDF; -$m-green: #DFF0D8; +$m-blue: #D8EAF0; +$m-gray: #DFDFDF; +$m-green: #DFF0D8; $m-orange: #FDC894; -$m-red: #EF9090; +$m-red: #EF9090; diff --git a/admin/css/debug-toolbar/_mixins.scss b/admin/css/debug-toolbar/_mixins.scss index c5bde5f5f6da..69af2b67c475 100644 --- a/admin/css/debug-toolbar/_mixins.scss +++ b/admin/css/debug-toolbar/_mixins.scss @@ -2,12 +2,13 @@ // ========================================================================== */ @mixin border-radius($radius) { - border-radius: $radius; - -moz-border-radius: $radius; - -webkit-border-radius: $radius; + border-radius: $radius; + -moz-border-radius: $radius; + -webkit-border-radius: $radius; } + @mixin box-shadow($left, $top, $radius, $color) { - box-shadow: $left $top $radius $color; - -moz-box-shadow: $left $top $radius $color; - -webkit-box-shadow: $left $top $radius $color; + box-shadow: $left $top $radius $color; + -moz-box-shadow: $left $top $radius $color; + -webkit-box-shadow: $left $top $radius $color; } diff --git a/admin/css/debug-toolbar/_settings.scss b/admin/css/debug-toolbar/_settings.scss index 06a51de634bb..1bb1386a46a0 100644 --- a/admin/css/debug-toolbar/_settings.scss +++ b/admin/css/debug-toolbar/_settings.scss @@ -1,7 +1,7 @@ // FONT // ========================================================================== */ -// Standard "sans-serif" font stack used by Github +// Standard "sans-serif" font stack used by GitHub $base-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; // Default size, all other styles are based on this size diff --git a/admin/css/debug-toolbar/_theme-dark.scss b/admin/css/debug-toolbar/_theme-dark.scss index 56d4a4bbbac3..801b75664f22 100644 --- a/admin/css/debug-toolbar/_theme-dark.scss +++ b/admin/css/debug-toolbar/_theme-dark.scss @@ -12,11 +12,14 @@ // ========================================================================== */ #debug-icon { - background-color: $t-dark; - @include box-shadow(0, 0, 4px, $m-gray); - a:active, a:link, a:visited { - color: $g-orange; - } + background-color: $t-dark; + @include box-shadow(0, 0, 4px, $m-gray); + + a:active, + a:link, + a:visited { + color: $g-orange; + } } @@ -24,119 +27,130 @@ // ========================================================================== */ #debug-bar { - background-color: $t-dark; - color: $m-gray; - - // Reset to prevent conflict with other CSS files - h1, - h2, - h3, - p, - a, - button, - table, - thead, - tr, - td, - button, - .toolbar { - background-color: transparent; - color: $m-gray; - } - - // Buttons - button { - background-color: $t-dark; - } - - // Tables - table { - strong { - color: $m-orange; - } - tbody tr { - &:hover { - background-color: $g-gray; - } - &.current { - background-color: $m-orange; - td { - color: $t-dark; - } - &:hover td { - background-color: $g-red; - color: $t-light; - } - } - } - } - - // The toolbar - .toolbar { - background-color: $g-gray; - @include box-shadow(0, 0, 4px, $g-gray); - img { - filter: brightness(0) invert(1); - } - } - - // Fixed top - &.fixed-top { - & .toolbar { - @include box-shadow(0, 0, 4px, $g-gray); - } - .tab { - @include box-shadow(0, 1px, 4px, $g-gray); - } - } - - // "Muted" elements - .muted { - color: $m-gray; - td { - color: $g-gray; - } - &:hover td { - color: $m-gray; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme, { - filter: brightness(0) invert(0.6); - } - - // The toolbar menus - .ci-label { - &.active { - background-color: $t-dark; - } - &:hover { - background-color: $t-dark; - } - .badge { - background-color: $g-blue; - color: $m-gray; - } - } - - // The tabs container - .tab { - background-color: $t-dark; - @include box-shadow(0, -1px, 4px, $g-gray); - } - - // The "Timeline" tab - .timeline { - th, - td { - border-color: $g-gray; - } - .timer { - background-color: $g-orange; - } - } + background-color: $t-dark; + color: $m-gray; + + // Reset to prevent conflict with other CSS files + h1, + h2, + h3, + p, + a, + button, + table, + thead, + tr, + td, + button, + .toolbar { + background-color: transparent; + color: $m-gray; + } + + // Buttons + button { + background-color: $t-dark; + } + + // Tables + table { + strong { + color: $g-orange; + } + + tbody tr { + &:hover { + background-color: $g-gray; + } + + &.current { + background-color: $m-orange; + + td { + color: $t-dark; + } + + &:hover td { + background-color: $g-red; + color: $t-light; + } + } + } + } + + // The toolbar + .toolbar { + background-color: $g-gray; + @include box-shadow(0, 0, 4px, $g-gray); + + img { + filter: brightness(0) invert(1); + } + } + + // Fixed top + &.fixed-top { + .toolbar { + @include box-shadow(0, 0, 4px, $g-gray); + } + + .tab { + @include box-shadow(0, 1px, 4px, $g-gray); + } + } + + // "Muted" elements + .muted { + color: $m-gray; + + td { + color: $g-gray; + } + + &:hover td { + color: $m-gray; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + filter: brightness(0) invert(0.6); + } + + // The toolbar menus + .ci-label { + &.active { + background-color: $t-dark; + } + + &:hover { + background-color: $t-dark; + } + + .badge { + background-color: $g-blue; + color: $m-gray; + } + } + + // The tabs container + .tab { + background-color: $t-dark; + @include box-shadow(0, -1px, 4px, $g-gray); + } + + // The "Timeline" tab + .timeline { + th, + td { + border-color: $g-gray; + } + + .timer { + background-color: $g-orange; + } + } } @@ -144,9 +158,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: $g-orange; } + .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: $m-orange; + color: $g-gray; } diff --git a/admin/css/debug-toolbar/_theme-light.scss b/admin/css/debug-toolbar/_theme-light.scss index 744997a4080d..41dbb9175fde 100644 --- a/admin/css/debug-toolbar/_theme-light.scss +++ b/admin/css/debug-toolbar/_theme-light.scss @@ -12,11 +12,14 @@ // ========================================================================== */ #debug-icon { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); - a:active, a:link, a:visited { - color: $g-orange; - } + background-color: $t-light; + @include box-shadow(0, 0, 4px, $m-gray); + + a:active, + a:link, + a:visited { + color: $g-orange; + } } @@ -24,116 +27,126 @@ // ========================================================================== */ #debug-bar { - background-color: $t-light; - color: $g-gray; - - // Reset to prevent conflict with other CSS files */ - h1, - h2, - h3, - p, - a, - button, - table, - thead, - tr, - td, - button, - .toolbar { - background-color: transparent; - color: $g-gray; - } - - // Buttons - button { - background-color: $t-light; - } - - // Tables - table { - strong { - color: $m-orange; - } - tbody tr { - &:hover { - background-color: $m-gray; - } - &.current { - background-color: $m-orange; - &:hover td { - background-color: $g-red; - color: $t-light; - } - } - } - } - - // The toolbar - .toolbar { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); - img { - filter: brightness(0) invert(0.4); - } - } - - // Fixed top - &.fixed-top { - & .toolbar { - @include box-shadow(0, 0, 4px, $m-gray); - } - .tab { - @include box-shadow(0, 1px, 4px, $m-gray); - } - } - - // "Muted" elements - .muted { - color: $g-gray; - td { - color: $m-gray; - } - &:hover td { - color: $g-gray; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme, { - filter: brightness(0) invert(0.6); - } - - // The toolbar menus - .ci-label { - &.active { - background-color: $m-gray; - } - &:hover { - background-color: $m-gray; - } - .badge { - background-color: $g-blue; - color: $t-light; - } - } - - // The tabs container - .tab { - background-color: $t-light; - @include box-shadow(0, -1px, 4px, $m-gray); - } - - // The "Timeline" tab - .timeline { - th, - td { - border-color: $m-gray; - } - .timer { - background-color: $g-orange; - } - } + background-color: $t-light; + color: $g-gray; + + // Reset to prevent conflict with other CSS files + h1, + h2, + h3, + p, + a, + button, + table, + thead, + tr, + td, + button, + .toolbar { + background-color: transparent; + color: $g-gray; + } + + // Buttons + button { + background-color: $t-light; + } + + // Tables + table { + strong { + color: $g-orange; + } + + tbody tr { + &:hover { + background-color: $m-gray; + } + + &.current { + background-color: $m-orange; + + &:hover td { + background-color: $g-red; + color: $t-light; + } + } + } + } + + // The toolbar + .toolbar { + background-color: $t-light; + @include box-shadow(0, 0, 4px, $m-gray); + + img { + filter: brightness(0) invert(0.4); + } + } + + // Fixed top + &.fixed-top { + .toolbar { + @include box-shadow(0, 0, 4px, $m-gray); + } + + .tab { + @include box-shadow(0, 1px, 4px, $m-gray); + } + } + + // "Muted" elements + .muted { + color: $g-gray; + + td { + color: $m-gray; + } + + &:hover td { + color: $g-gray; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + filter: brightness(0) invert(0.6); + } + + // The toolbar menus + .ci-label { + &.active { + background-color: $m-gray; + } + + &:hover { + background-color: $m-gray; + } + + .badge { + background-color: $g-blue; + color: $t-light; + } + } + + // The tabs container + .tab { + background-color: $t-light; + @include box-shadow(0, -1px, 4px, $m-gray); + } + + // The "Timeline" tab + .timeline { + th, + td { + border-color: $m-gray; + } + + .timer { + background-color: $g-orange; + } + } } @@ -141,9 +154,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: $g-orange; } + .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: $m-orange; + color: $g-gray; } diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index 8669d0ac12ff..c73510182ac7 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -1,9 +1,10 @@ -/*! CodeIgniter 4 - Debug bar - * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com - * Website: https://codeigniter.com +/** + * This file is part of the CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ // IMPORTS @@ -17,38 +18,38 @@ // ========================================================================== */ #debug-icon { - // Position - bottom: 0; - position: fixed; - right: 0; - z-index: 10000; - - // Size - height: 36px; - width: 36px; - - // Spacing - margin: 0px; - padding: 0px; - - // Content - clear: both; - text-align: center; - - a svg { - margin: 8px; - max-width: 20px; - max-height: 20px; - } - - &.fixed-top { - bottom: auto; - top: 0; - } - - .debug-bar-ndisplay { - display: none; - } + // Position + bottom: 0; + position: fixed; + right: 0; + z-index: 10000; + + // Size + height: 36px; + width: 36px; + + // Spacing + margin: 0px; + padding: 0px; + + // Content + clear: both; + text-align: center; + + a svg { + margin: 8px; + max-width: 20px; + max-height: 20px; + } + + &.fixed-top { + bottom: auto; + top: 0; + } + + .debug-bar-ndisplay { + display: none; + } } @@ -56,299 +57,352 @@ // ========================================================================== */ #debug-bar { - // Position - bottom: 0; - left: 0; - position: fixed; - right: 0; - z-index: 10000; - - // Size - height: 36px; - - // Spacing - line-height: 36px; - - // Typography - font-family: $base-font; - font-size: $base-size; - font-weight: 400; - - // General elements - h1 { - bottom: 0; - display: inline-block; - font-size: $base-size - 2; - font-weight: normal; - margin: 0 16px 0 0; - padding: 0; - position: absolute; - right: 30px; - text-align: left; - top: 0; - - svg { - width: 16px; - margin-right: 5px; - } - } - - h2 { - font-size: $base-size; - margin: 0; - padding: 5px 0 10px 0; - - span { - font-size: 13px; - } - } - - h3 { - font-size: $base-size - 4; - font-weight: 200; - margin: 0 0 0 10px; - padding: 0; - text-transform: uppercase; - } - - p { - font-size: $base-size - 4; - margin: 0 0 0 15px; - padding: 0; - } - - a { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - button { - border: 1px solid; - @include border-radius(4px); - cursor: pointer; - line-height: 15px; - - &:hover { - text-decoration: underline; - } - } - - table { - border-collapse: collapse; - font-size: $base-size - 2; - line-height: normal; - margin: 5px 10px 15px 10px; // Tables indentation - width: calc(100% - 10px); // Make sure it still fits the container, even with the margins - - strong { - font-weight: 500; - } - - th { - display: table-cell; - font-weight: 600; - padding-bottom: 0.7em; - text-align: left; - } - - tr { - border: none; - } - - td { - border: none; - display: table-cell; - margin: 0; - text-align: left; - - &:first-child { - max-width: 20%; - - &.narrow { - width: 7em; - } - } - } - } - - td[data-debugbar-route] { - form { - display: none; - } - - &:hover { - form { - display: block; - } - - &>div { - display: none; - } - } - - input[type=text] { - padding: 2px; - } - } - - // The toolbar - .toolbar { - display: flex; - overflow: hidden; - overflow-y: auto; - padding: 0 12px 0 12px; - /* give room for OS X scrollbar */ - white-space: nowrap; - z-index: 10000; - } - - // Fixed top - &.fixed-top { - bottom: auto; - top: 0; - - .tab { - bottom: auto; - top: 36px; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme { - a { - // float: left; - padding: 0 6px; - display: inline-flex; - vertical-align: top; - - &:hover { - text-decoration: none; - } - } - } - - // The "Open/Close" toggle - #debug-bar-link { - bottom: 0; - display: inline-block; - font-size: $base-size; - line-height: 36px; - padding: 6px; - position: absolute; - right: 10px; - top: 0; - width: 24px; - } - - // The toolbar menus - .ci-label { - display: inline-flex; - font-size: $base-size - 2; - // vertical-align: baseline; - - &:hover { - cursor: pointer; - } - - a { - color: inherit; - display: flex; - letter-spacing: normal; - padding: 0 10px; - text-decoration: none; - align-items: center; - } - - // The toolbar icons - img { - // clear: left; - // display: inline-block; - // float: left; - margin: 6px 3px 6px 0; - width: 16px !important; - } - - // The toolbar notification badges - .badge { - @include border-radius(12px); - display: inline-block; - font-size: 75%; - font-weight: bold; - line-height: 12px; - margin-left: 5px; - padding: 2px 5px; - text-align: center; - vertical-align: baseline; - white-space: nowrap; - } - } - - // The tabs container - .tab { - bottom: 35px; - display: none; - left: 0; - max-height: 62%; - overflow: hidden; - overflow-y: auto; - padding: 1em 2em; - position: fixed; - right: 0; - z-index: 9999; - } - - // The "Timeline" tab - .timeline { - margin-left: 0; - width: 100%; - - th { - border-left: 1px solid; - font-size: $base-size - 4; - font-weight: 200; - padding: 5px 5px 10px 5px; - position: relative; - text-align: left; - - &:first-child { - border-left: 0; - } - } - - td { - border-left: 1px solid; - padding: 5px; - position: relative; - - &:first-child { - border-left: 0; - } - } - - .timer { - @include border-radius(4px); - display: inline-block; - padding: 5px; - position: absolute; - top: 30%; - } - } - - // The "Routes" tab - .route-params, - .route-params-item { - vertical-align: top; - - td:first-child { - font-style: italic; - padding-left: 1em; - text-align: right; - } - } + // Position + bottom: 0; + left: 0; + position: fixed; + right: 0; + z-index: 10000; + + // Size + height: 36px; + + // Spacing + line-height: 36px; + + // Typography + font-family: $base-font; + font-size: $base-size; + font-weight: 400; + + // General elements + h1 { + bottom: 0; + display: inline-block; + font-size: $base-size - 2; + font-weight: normal; + margin: 0 16px 0 0; + padding: 0; + position: absolute; + right: 30px; + text-align: left; + top: 0; + + svg { + width: 16px; + margin-right: 5px; + } + } + + h2 { + font-size: $base-size; + margin: 0; + padding: 5px 0 10px 0; + + span { + font-size: 13px; + } + } + + h3 { + font-size: $base-size - 4; + font-weight: 200; + margin: 0 0 0 10px; + padding: 0; + text-transform: uppercase; + } + + p { + font-size: $base-size - 4; + margin: 0 0 0 15px; + padding: 0; + } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + button { + border: 1px solid; + @include border-radius(4px); + cursor: pointer; + line-height: 15px; + + &:hover { + text-decoration: underline; + } + } + + table { + border-collapse: collapse; + font-size: $base-size - 2; + line-height: normal; + + // Tables indentation + margin: 5px 10px 15px 10px; + + // Make sure it still fits the container, even with the margins + width: calc(100% - 10px); + + strong { + font-weight: 500; + } + + th { + display: table-cell; + font-weight: 600; + padding-bottom: 0.7em; + text-align: left; + } + + tr { + border: none; + } + + td { + border: none; + display: table-cell; + margin: 0; + text-align: left; + + &:first-child { + max-width: 20%; + + &.narrow { + width: 7em; + } + } + } + } + + td[data-debugbar-route] { + form { + display: none; + } + + &:hover { + form { + display: block; + } + + &>div { + display: none; + } + } + + input[type=text] { + padding: 2px; + } + } + + // The toolbar + .toolbar { + display: flex; + overflow: hidden; + overflow-y: auto; + padding: 0 12px 0 12px; + + // Give room for OS X scrollbar + white-space: nowrap; + z-index: 10000; + } + + // Fixed top + &.fixed-top { + bottom: auto; + top: 0; + + .tab { + bottom: auto; + top: 36px; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + a { + padding: 0 6px; + display: inline-flex; + vertical-align: top; + + &:hover { + text-decoration: none; + } + } + } + + // The "Open/Close" toggle + #debug-bar-link { + bottom: 0; + display: inline-block; + font-size: $base-size; + line-height: 36px; + padding: 6px; + position: absolute; + right: 10px; + top: 0; + width: 24px; + } + + // The toolbar menus + .ci-label { + display: inline-flex; + font-size: $base-size - 2; + + &:hover { + cursor: pointer; + } + + a { + color: inherit; + display: flex; + letter-spacing: normal; + padding: 0 10px; + text-decoration: none; + align-items: center; + } + + // The toolbar icons + img { + margin: 6px 3px 6px 0; + width: 16px !important; + } + + // The toolbar notification badges + .badge { + @include border-radius(12px); + display: inline-block; + font-size: 75%; + font-weight: bold; + line-height: 12px; + margin-left: 5px; + padding: 2px 5px; + text-align: center; + vertical-align: baseline; + white-space: nowrap; + } + } + + // The tabs container + .tab { + bottom: 35px; + display: none; + left: 0; + max-height: 62%; + overflow: hidden; + overflow-y: auto; + padding: 1em 2em; + position: fixed; + right: 0; + z-index: 9999; + } + + // The "Timeline" tab + .timeline { + margin-left: 0; + width: 100%; + + th { + border-left: 1px solid; + font-size: $base-size - 4; + font-weight: 200; + padding: 5px 5px 10px 5px; + position: relative; + text-align: left; + + &:first-child { + border-left: 0; + } + } + + td { + border-left: 1px solid; + padding: 5px; + position: relative; + + &:first-child { + border-left: 0; + max-width: none; + } + + &.child-container { + padding: 0px; + + .timeline { + margin: 0px; + + td { + &:first-child { + &:not(.child-container) { + padding-left: calc(5px + 10px * var(--level)); + } + } + } + } + } + } + + .timer { + @include border-radius(4px); + display: inline-block; + padding: 5px; + position: absolute; + top: 30%; + } + + .timeline-parent { + cursor: pointer; + + td { + &:first-child { + nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; + } + } + } + } + + .timeline-parent-open { + background-color: #DFDFDF; + + td { + &:first-child { + nav { + background-position: 0 75%; + } + } + } + } + + .child-row { + &:hover { + background: transparent; + } + } + } + + // The "Routes" tab + .route-params, + .route-params-item { + vertical-align: top; + + td:first-child { + font-style: italic; + padding-left: 1em; + text-align: right; + } + } } @@ -356,21 +410,21 @@ // ========================================================================== */ .debug-view.show-view { - border: 1px solid; - margin: 4px; + border: 1px solid; + margin: 4px; } .debug-view-path { - font-family: monospace; - font-size: $base-size - 4; - letter-spacing: normal; - min-height: 16px; - padding: 2px; - text-align: left; + font-family: monospace; + font-size: $base-size - 4; + letter-spacing: normal; + min-height: 16px; + padding: 2px; + text-align: left; } .show-view .debug-view-path { - display: block !important; + display: block !important; } @@ -378,17 +432,17 @@ // ========================================================================== */ @media screen and (max-width: 1024px) { - #debug-bar { - .ci-label { - img { - margin: unset - } - } - } - - .hide-sm { - display: none !important; - } + #debug-bar { + .ci-label { + img { + margin: unset + } + } + } + + .hide-sm { + display: none !important; + } } @@ -400,22 +454,22 @@ // If the browser supports "prefers-color-scheme" and the scheme is "Dark" @media (prefers-color-scheme: dark) { - @import '_theme-dark'; + @import '_theme-dark'; } // If we force the "Dark" theme #toolbarContainer.dark { - @import '_theme-dark'; + @import '_theme-dark'; - td[data-debugbar-route] input[type=text] { - background: #000; - color: #fff; - } + td[data-debugbar-route] input[type=text] { + background: #000; + color: #fff; + } } // If we force the "Light" theme #toolbarContainer.light { - @import '_theme-light'; + @import '_theme-light'; } @@ -423,41 +477,41 @@ // ========================================================================== */ .debug-bar-width30 { - width: 30%; + width: 30%; } .debug-bar-width10 { - width: 10%; + width: 10%; } .debug-bar-width70p { - width: 70px; + width: 70px; } .debug-bar-width140p { - width: 140px; + width: 140px; } .debug-bar-width20e { - width: 20em; + width: 20em; } .debug-bar-width6r { - width: 6rem; + width: 6rem; } .debug-bar-ndisplay { - display: none; + display: none; } .debug-bar-alignRight { - text-align: right; + text-align: right; } .debug-bar-alignLeft { - text-align: left; + text-align: left; } .debug-bar-noverflow { - overflow: hidden; -} \ No newline at end of file + overflow: hidden; +} diff --git a/admin/framework/README.md b/admin/framework/README.md index 1f95a415d37a..6b7c6673e33a 100644 --- a/admin/framework/README.md +++ b/admin/framework/README.md @@ -28,7 +28,7 @@ framework are exposed. ## Repository Management -We use Github issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages. +We use GitHub issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages. We use our [forum](http://forum.codeigniter.com) to provide SUPPORT and to discuss FEATURE REQUESTS. diff --git a/admin/framework/composer.json b/admin/framework/composer.json index 6e3852d2af79..6c02a4b71eb9 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -11,7 +11,7 @@ "ext-json": "*", "ext-mbstring": "*", "kint-php/kint": "^3.3", - "laminas/laminas-escaper": "^2.8", + "laminas/laminas-escaper": "^2.9", "psr/log": "^1.1" }, "require-dev": { diff --git a/admin/module/tests/_support/Models/ExampleModel.php b/admin/module/tests/_support/Models/ExampleModel.php index a71009e74f8b..f0687e9b1985 100644 --- a/admin/module/tests/_support/Models/ExampleModel.php +++ b/admin/module/tests/_support/Models/ExampleModel.php @@ -6,22 +6,18 @@ class ExampleModel extends Model { - protected $table = 'factories'; - protected $primaryKey = 'id'; - + protected $table = 'factories'; + protected $primaryKey = 'id'; protected $returnType = 'object'; protected $useSoftDeletes = false; - - protected $allowedFields = [ + protected $allowedFields = [ 'name', 'uid', 'class', 'icon', 'summary', ]; - - protected $useTimestamps = true; - + protected $useTimestamps = true; protected $validationRules = []; protected $validationMessages = []; protected $skipValidation = false; diff --git a/admin/pre-commit b/admin/pre-commit index d05724d58146..4f1e7d11f544 100644 --- a/admin/pre-commit +++ b/admin/pre-commit @@ -1,24 +1,14 @@ #!/bin/sh PROJECT=`php -r "echo dirname(dirname(dirname(realpath('$0'))));"` -STAGED_FILES_CMD=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php$` - -# Determine if a file list is passed -if [ "$#" -eq 1 ]; then - oIFS=$IFS - IFS=' - ' - SFILES="$1" - IFS=$oIFS -fi - -SFILES=${SFILES:-$STAGED_FILES_CMD} +STAGED_PHP_FILES=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php$` +STAGED_RST_FILES=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.rst$` echo "Starting CodeIgniter precommit..." -if [ "$SFILES" != "" ]; then +if [ "$STAGED_PHP_FILES" != "" ]; then echo "Linting PHP code..." - for FILE in $SFILES; do + for FILE in $STAGED_PHP_FILES; do php -l -d display_errors=0 "$PROJECT/$FILE" if [ $? != 0 ]; then @@ -52,9 +42,9 @@ if [ "$FILES" != "" ]; then # Run on whole codebase to skip on unnecessary filtering # Run first on app, admin, public if [ -d /proc/cygdrive ]; then - ./vendor/bin/php-cs-fixer fix --verbose --dry-run --using-cache=no --diff --config=.no-header.php-cs-fixer.dist.php + ./vendor/bin/php-cs-fixer fix --verbose --dry-run --diff --config=.no-header.php-cs-fixer.dist.php else - php ./vendor/bin/php-cs-fixer fix --verbose --dry-run --using-cache=no --diff --config=.no-header.php-cs-fixer.dist.php + php ./vendor/bin/php-cs-fixer fix --verbose --dry-run --diff --config=.no-header.php-cs-fixer.dist.php fi if [ $? != 0 ]; then @@ -64,9 +54,9 @@ if [ "$FILES" != "" ]; then # Next, run on system, tests, utils, and root PHP files if [ -d /proc/cygdrive ]; then - ./vendor/bin/php-cs-fixer fix --verbose --dry-run --using-cache=no --diff + ./vendor/bin/php-cs-fixer fix --verbose --dry-run --diff else - php ./vendor/bin/php-cs-fixer fix --verbose --dry-run --using-cache=no --diff + php ./vendor/bin/php-cs-fixer fix --verbose --dry-run --diff fi if [ $? != 0 ]; then @@ -75,4 +65,9 @@ if [ "$FILES" != "" ]; then fi fi +if [ "$STAGED_RST_FILES" != "" ]; then + echo "Checking for tabs in RST files" + php ./utils/check_tabs_in_rst.php +fi + exit $? diff --git a/admin/starter/README.md b/admin/starter/README.md index 41ce92110d7a..363e7c89f304 100644 --- a/admin/starter/README.md +++ b/admin/starter/README.md @@ -41,7 +41,7 @@ framework are exposed. ## Repository Management -We use Github issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages. +We use GitHub issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages. We use our [forum](http://forum.codeigniter.com) to provide SUPPORT and to discuss FEATURE REQUESTS. diff --git a/admin/starter/builds b/admin/starter/builds index 268e7a819c22..0b10a150ac59 100755 --- a/admin/starter/builds +++ b/admin/starter/builds @@ -15,149 +15,111 @@ define('GITHUB_URL', 'https://github.com/codeigniter4/codeigniter4'); */ // Determine the requested stability -if (empty($argv[1]) || ! in_array($argv[1], ['release', 'development'])) -{ - echo 'Usage: php builds [release|development]' . PHP_EOL; - exit; +if (empty($argv[1]) || ! in_array($argv[1], ['release', 'development'], true)) { + echo 'Usage: php builds [release|development]' . PHP_EOL; + + exit; } -$dev = $argv[1] == 'development'; +$dev = $argv[1] === 'development'; + $modified = []; -/* Locate each file and update it for the requested stability */ +// Locate each file and update it for the requested stability -// Composer.json $file = __DIR__ . DIRECTORY_SEPARATOR . 'composer.json'; -if (is_file($file)) -{ - // Make sure we can read it - if ($contents = file_get_contents($file)) - { - if ($array = json_decode($contents, true)) - { - // Development - if ($dev) - { - // Set 'minimum-stability' - $array['minimum-stability'] = 'dev'; - $array['prefer-stable'] = true; - - // Make sure the repo is configured - if (! isset($array['repositories'])) - { - $array['repositories'] = []; - } - - // Check for the CodeIgniter repo - $found = false; - foreach ($array['repositories'] as $repository) - { - if ($repository['url'] == GITHUB_URL) - { - $found = true; - break; - } - } - - // Add the repo if it was not found - if (! $found) - { - $array['repositories'][] = [ - 'type' => 'vcs', - 'url' => GITHUB_URL, - ]; - } - - // Define the "require" - $array['require']['codeigniter4/codeigniter4'] = 'dev-develop'; - unset($array['require']['codeigniter4/framework']); - } - - // Release - else - { - // Clear 'minimum-stability' - unset($array['minimum-stability']); - - // If the repo is configured then clear it - if (isset($array['repositories'])) - { - // Check for the CodeIgniter repo - foreach ($array['repositories'] as $i => $repository) - { - if ($repository['url'] == GITHUB_URL) - { - unset($array['repositories'][$i]); - break; - } - } - if (empty($array['repositories'])) - { - unset($array['repositories']); - } - } - - // Define the "require" - $array['require']['codeigniter4/framework'] = LATEST_RELEASE; - unset($array['require']['codeigniter4/codeigniter4']); - } - - // Write out a new composer.json - file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) . PHP_EOL); - $modified[] = $file; - } - else - { - echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL; - } - } - else - { - echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL; - } +if (is_file($file)) { + $contents = file_get_contents($file); + + if ((string) $contents !== '') { + $array = json_decode($contents, true); + + if (is_array($array)) { + if ($dev) { + $array['minimum-stability'] = 'dev'; + $array['prefer-stable'] = true; + $array['repositories'] = $array['repositories'] ?? []; + + $found = false; + + foreach ($array['repositories'] as $repository) { + if ($repository['url'] === GITHUB_URL) { + $found = true; + break; + } + } + + if (! $found) { + $array['repositories'][] = [ + 'type' => 'vcs', + 'url' => GITHUB_URL, + ]; + } + + $array['require']['codeigniter4/codeigniter4'] = 'dev-develop'; + unset($array['require']['codeigniter4/framework']); + } else { + unset($array['minimum-stability']); + + if (isset($array['repositories'])) { + foreach ($array['repositories'] as $i => $repository) { + if ($repository['url'] === GITHUB_URL) { + unset($array['repositories'][$i]); + break; + } + } + + if (empty($array['repositories'])) { + unset($array['repositories']); + } + } + + $array['require']['codeigniter4/framework'] = LATEST_RELEASE; + unset($array['require']['codeigniter4/codeigniter4']); + } + + file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $modified[] = $file; + } else { + echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL; + } + } else { + echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL; + } } -// Paths config and PHPUnit XMLs $files = [ - __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', - __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist', - __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', + __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', ]; -foreach ($files as $file) -{ - if (is_file($file)) - { - $contents = file_get_contents($file); - - // Development - if ($dev) - { - $contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents); - } - - // Release - else - { - $contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents); - } - - file_put_contents($file, $contents); - $modified[] = $file; - } -} +foreach ($files as $file) { + if (is_file($file)) { + $contents = file_get_contents($file); + + if ($dev) { + $contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents); + } else { + $contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents); + } -if (empty($modified)) -{ - echo 'No files modified' . PHP_EOL; + file_put_contents($file, $contents); + + $modified[] = $file; + } } -else -{ - echo 'The following files were modified:' . PHP_EOL; - foreach ($modified as $file) - { - echo " * {$file}" . PHP_EOL; - } - echo 'Run `composer update` to sync changes with your vendor folder' . PHP_EOL; + +if ($modified === []) { + echo 'No files modified.' . PHP_EOL; +} else { + echo 'The following files were modified:' . PHP_EOL; + + foreach ($modified as $file) { + echo " * {$file}" . PHP_EOL; + } + + echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL; } diff --git a/admin/starter/tests/_support/DatabaseTestCase.php b/admin/starter/tests/_support/DatabaseTestCase.php deleted file mode 100644 index fd067a585c87..000000000000 --- a/admin/starter/tests/_support/DatabaseTestCase.php +++ /dev/null @@ -1,61 +0,0 @@ -mockSession(); - } - - /** - * Pre-loads the mock session driver into $this->session. - * - * @var string - */ - protected function mockSession() - { - $config = config('App'); - $this->session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config); - \Config\Services::injectMock('session', $this->session); - } -} diff --git a/admin/starter/tests/database/ExampleDatabaseTest.php b/admin/starter/tests/database/ExampleDatabaseTest.php index 203b1261c7e0..5d13836f9dc6 100644 --- a/admin/starter/tests/database/ExampleDatabaseTest.php +++ b/admin/starter/tests/database/ExampleDatabaseTest.php @@ -1,18 +1,15 @@ session->set('logged_in', 123); - - $value = $this->session->get('logged_in'); - - $this->assertSame(123, $value); + $this->assertSame(123, $this->session->get('logged_in')); } } diff --git a/admin/starter/tests/unit/HealthTest.php b/admin/starter/tests/unit/HealthTest.php index f834ac605b40..ab3e2aa1d524 100644 --- a/admin/starter/tests/unit/HealthTest.php +++ b/admin/starter/tests/unit/HealthTest.php @@ -1,39 +1,36 @@ assertTrue($test); + $this->assertTrue(defined('APPPATH')); } public function testBaseUrlHasBeenSet() { $validation = Services::validation(); - $env = false; + + $env = false; // Check the baseURL in .env if (is_file(HOMEPATH . '.env')) { - $env = (bool) preg_grep('/^app\.baseURL = ./', file(HOMEPATH . '.env')); + $env = preg_grep('/^app\.baseURL = ./', file(HOMEPATH . '.env')) !== false; } if ($env) { // BaseURL in .env is a valid URL? // phpunit.xml.dist sets app.baseURL in $_SERVER // So if you set app.baseURL in .env, it takes precedence - $config = new Config\App(); + $config = new App(); $this->assertTrue( $validation->check($config->baseURL, 'valid_url'), 'baseURL "' . $config->baseURL . '" in .env is not valid URL' @@ -42,7 +39,7 @@ public function testBaseUrlHasBeenSet() // Get the baseURL in app/Config/App.php // You can't use Config\App, because phpunit.xml.dist sets app.baseURL - $reader = new \Tests\Support\Libraries\ConfigReader(); + $reader = new ConfigReader(); // BaseURL in app/Config/App.php is a valid URL? $this->assertTrue( diff --git a/app/Config/CURLRequest.php b/app/Config/CURLRequest.php new file mode 100644 index 000000000000..b4c8e5c4f13c --- /dev/null +++ b/app/Config/CURLRequest.php @@ -0,0 +1,22 @@ + 'CodeIgniter\Commands\Generators\Views\command.tpl.php', + 'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php', 'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php', 'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php', 'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php', diff --git a/app/Config/Kint.php b/app/Config/Kint.php index 4b52422af7d7..b1016ed57923 100644 --- a/app/Config/Kint.php +++ b/app/Config/Kint.php @@ -24,26 +24,19 @@ class Kint extends BaseConfig */ public $plugins; - - public $maxDepth = 6; - + public $maxDepth = 6; public $displayCalledFrom = true; - - public $expanded = false; + public $expanded = false; /* |-------------------------------------------------------------------------- | RichRenderer Settings |-------------------------------------------------------------------------- */ - public $richTheme = 'aante-light.css'; - + public $richTheme = 'aante-light.css'; public $richFolder = false; - - public $richSort = Renderer::SORT_FULL; - + public $richSort = Renderer::SORT_FULL; public $richObjectPlugins; - public $richTabPlugins; /* @@ -51,11 +44,8 @@ class Kint extends BaseConfig | CLI Settings |-------------------------------------------------------------------------- */ - public $cliColors = true; - - public $cliForceUTF8 = false; - + public $cliColors = true; + public $cliForceUTF8 = false; public $cliDetectWidth = true; - - public $cliMinWidth = 40; + public $cliMinWidth = 40; } diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php new file mode 100644 index 000000000000..f3768bc577b4 --- /dev/null +++ b/app/Config/Publisher.php @@ -0,0 +1,28 @@ + + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; +} diff --git a/app/Config/Security.php b/app/Config/Security.php index c4cf7a25f42e..563cf2f3a86e 100644 --- a/app/Config/Security.php +++ b/app/Config/Security.php @@ -6,12 +6,23 @@ class Security extends BaseConfig { + /** + * -------------------------------------------------------------------------- + * CSRF Protection Method + * -------------------------------------------------------------------------- + * + * Protection Method for Cross Site Request Forgery protection. + * + * @var string 'cookie' or 'session' + */ + public $csrfProtection = 'cookie'; + /** * -------------------------------------------------------------------------- * CSRF Token Name * -------------------------------------------------------------------------- * - * Token name for Cross Site Request Forgery protection cookie. + * Token name for Cross Site Request Forgery protection. * * @var string */ @@ -22,7 +33,7 @@ class Security extends BaseConfig * CSRF Header Name * -------------------------------------------------------------------------- * - * Token name for Cross Site Request Forgery protection cookie. + * Header name for Cross Site Request Forgery protection. * * @var string */ @@ -33,7 +44,7 @@ class Security extends BaseConfig * CSRF Cookie Name * -------------------------------------------------------------------------- * - * Cookie name for Cross Site Request Forgery protection cookie. + * Cookie name for Cross Site Request Forgery protection. * * @var string */ @@ -57,7 +68,7 @@ class Security extends BaseConfig * CSRF Regenerate * -------------------------------------------------------------------------- * - * Regenerate CSRF Token on every request. + * Regenerate CSRF Token on every submission. * * @var bool */ diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index 7050aa91174a..9ee2e427c308 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -163,7 +163,7 @@ color: rgba(200, 200, 200, 1); padding: .25rem 1.75rem; } - @media (max-width: 559px) { + @media (max-width: 629px) { header ul { padding: 0; } diff --git a/composer.json b/composer.json index 7b9d50b8e620..b7b3176e055f 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "ext-json": "*", "ext-mbstring": "*", "kint-php/kint": "^3.3", - "laminas/laminas-escaper": "^2.8", + "laminas/laminas-escaper": "^2.9", "psr/log": "^1.1" }, "require-dev": { @@ -24,8 +24,7 @@ "phpstan/phpstan": "^0.12.91", "phpunit/phpunit": "^9.1", "predis/predis": "^1.1", - "rector/rector": "0.11.52", - "symplify/package-builder": "^9.3" + "rector/rector": "0.11.60" }, "suggest": { "ext-fileinfo": "Improves mime type detection for files" @@ -60,7 +59,21 @@ "bash -c \"if [ -f admin/setup.sh ]; then bash admin/setup.sh; fi\"" ], "analyze": "phpstan analyse", - "test": "phpunit" + "test": "phpunit", + "cs": [ + "php-cs-fixer fix --verbose --dry-run --diff --config=.no-header.php-cs-fixer.dist.php", + "php-cs-fixer fix --verbose --dry-run --diff" + ], + "cs-fix": [ + "php-cs-fixer fix --verbose --diff --config=.no-header.php-cs-fixer.dist.php", + "php-cs-fixer fix --verbose --diff" + ] + }, + "scripts-descriptions": { + "analyze": "Run static analysis", + "test": "Run unit tests", + "cs": "Check the coding style", + "cs-fix": "Fix the coding style" }, "support": { "forum": "http://forum.codeigniter.com/", diff --git a/contributing/DCO.md b/contributing/DCO.md new file mode 100644 index 000000000000..c3c806b0af1d --- /dev/null +++ b/contributing/DCO.md @@ -0,0 +1,20 @@ +# Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +1. The contribution was created in whole or in part by me and I have + the right to submit it under the open source license indicated in + the file; or +2. The contribution is based upon previous work that, to the best of my + knowledge, is covered under an appropriate open source license and I + have the right under that license to submit that work with + modifications, whether created in whole or in part by me, under the + same open source license (unless I am permitted to submit under a + different license), as indicated in the file; or +3. The contribution was provided directly to me by some other person + who certified (1), (2) or (3) and I have not modified it. +4. I understand and agree that this project and the contribution are + public and that a record of the contribution (including all personal + information I submit with it, including my sign-off) is maintained + indefinitely and may be redistributed consistent with this project + or the open source license(s) involved. diff --git a/contributing/DCO.rst b/contributing/DCO.rst deleted file mode 100644 index c8f9b49c6f84..000000000000 --- a/contributing/DCO.rst +++ /dev/null @@ -1,27 +0,0 @@ -##################################### -Developer's Certificate of Origin 1.1 -##################################### - -By making a contribution to this project, I certify that: - -(1) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(2) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(3) The contribution was provided directly to me by some other - person who certified (1), (2) or (3) and I have not modified - it. - -(4) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. diff --git a/contributing/README.md b/contributing/README.md new file mode 100644 index 000000000000..40796df376b3 --- /dev/null +++ b/contributing/README.md @@ -0,0 +1,16 @@ +# Contributing to CodeIgniter + +CodeIgniter is a community driven project and accepts contributions of +code and documentation from the community. These contributions are made +in the form of Issues or [Pull +Requests](https://help.github.com/articles/using-pull-requests/) on the +[CodeIgniter4 repository](https://github.com/codeigniter4/CodeIgniter4) +on GitHub. + +We will try to manage the process somewhat, by adding a ["help wanted" label](https://github.com/codeigniter4/CodeIgniter4/labels/help%20wanted) to those that we are +specifically interested in at any point in time. Join the discussion for those issues and let us know +if you want to take the lead on one of them. + +- [Contributor Covenant Code of Conduct](../CODE_OF_CONDUCT.md) +- [Reporting a Bug](./bug_report.md) +- [Sending a Pull Request](./pull_request.md) diff --git a/contributing/README.rst b/contributing/README.rst deleted file mode 100644 index b5539f8a8ae1..000000000000 --- a/contributing/README.rst +++ /dev/null @@ -1,78 +0,0 @@ -########################### -Contributing to CodeIgniter -########################### - -- `Contribution guidelines <./guidelines.rst>`_ -- `Contribution workflow <./workflow.rst>`_ -- `Contribution signing <./signing.rst>`_ -- `Contribution CSS <./css.rst>`_ -- `Framework internals <./internals.rst>`_ -- `CodeIgniter documentation <./documentation.rst>`_ -- `PHP Style Guide <./styleguide.rst>`_ -- `Developer's Certificate of Origin <../DCO.txt>`_ - -CodeIgniter is a community driven project and accepts contributions of code -and documentation from the community. These contributions are made in the form -of Issues or `Pull Requests `_ -on the `CodeIgniter4 repository `_ on GitHub. - -Issues are a quick way to point out a bug. If you find a bug or documentation -error in CodeIgniter then please check a few things first: - -- There is not already an open Issue -- The issue has already been fixed (check the develop branch, or look for - closed Issues) -- Is it something really obvious that you fix it yourself? - -Reporting issues is helpful but an even better approach is to send a Pull -Request, which is done by "Forking" the main repository and committing to your -own copy. This will require you to use the version control system called Git. - -******* -Support -******* - -Please note that GitHub is not for general support questions! If you are -having trouble using a feature of CodeIgniter, ask for help on our -`forums `_ instead. - -If you are not sure whether you are using something correctly or if you -have found a bug, again - please ask on the forums first. - -******** -Security -******** - -Did you find a security issue in CodeIgniter? - -Please *don't* disclose it publicly, but e-mail us at security@codeigniter.com, -or report it via our page on `HackerOne `_. - -If you've found a critical vulnerability, we'd be happy to credit you in our -`ChangeLog `_. - -**************************** -Tips for a Good Issue Report -**************************** - -Use a descriptive subject line (eg parser library chokes on commas) rather than -a vague one (eg. your code broke). - -Address a single issue in a report. - -Identify the CodeIgniter version (eg 4.0.1) and the component if you know it (eg. parser library) - -Explain what you expected to happen, and what did happen. -Include error messages and stacktrace, if any. - -Include short code segments if they help to explain. -Use a pastebin or dropbox facility to include longer segments of code or -screenshots - do not include them in the issue report itself. -This means setting a reasonable expiry for those, until the issue is resolved or closed. - -If you know how to fix the issue, you can do so in your own fork & branch, and submit a pull request. -The issue report information above should be part of that. - -If your issue report can describe the steps to reproduce the problem, that is great. -If you can include a unit test that reproduces the problem, that is even better, -as it gives whoever is fixing it a clearer target! diff --git a/contributing/bug_report.md b/contributing/bug_report.md new file mode 100644 index 000000000000..c564a9a734f4 --- /dev/null +++ b/contributing/bug_report.md @@ -0,0 +1,65 @@ +# Reporting a Bug + +## Issues + +Issues are a quick way to point out a bug. If you find a bug or documentation error in CodeIgniter then please make sure that: + +1. There is not already an open [Issue](https://github.com/codeigniter4/CodeIgniter4/issues) +2. The Issue has not already been fixed (check the develop branch or look for [closed Issues](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aissue+is%3Aclosed)) +3. It's not something really obvious that you can fix yourself + +Reporting Issues is helpful, but an even [better approach](./workflow.md) is to send a +[Pull Request](https://help.github.com/en/articles/creating-a-pull-request), which is done by +[Forking](https://help.github.com/en/articles/fork-a-repo) the main repository and making +a [Commit](https://help.github.com/en/desktop/contributing-to-projects/committing-and-reviewing-changes-to-your-project) +to your own copy of the project. This will require you to use the version control system called [Git](https://git-scm.com/). + +## Support + +Please note that GitHub is not for general support questions! If you are +having trouble using a feature, you can: + +- Start a new thread on our [Forums](http://forum.codeigniter.com/) +- Ask your questions on [Slack](https://codeigniterchat.slack.com/) + +If you are not sure whether you are using something correctly or if you +have found a bug, again - please ask on the forums first. + +## Security + +Did you find a security issue in CodeIgniter? + +Please *don't* disclose it publicly, but e-mail us at +, or report it via our page on +[HackerOne](https://hackerone.com/codeigniter). + +If you've found a critical vulnerability, we'd be happy to credit you in +our +[ChangeLog](https://codeigniter4.github.io/CodeIgniter4/changelogs/index.html). + +## Tips for a Good Issue Report + +Use a descriptive subject line (eg parser library chokes on commas) +rather than a vague one (eg. your code broke). + +Address a single issue in a report. + +Identify the CodeIgniter version (eg 4.0.1) and the component if you +know it (eg. parser library) + +Explain what you expected to happen, and what did happen. Include error +messages and stacktrace, if any. + +Include short code segments if they help to explain. Use a pastebin or +dropbox facility to include longer segments of code or screenshots - do +not include them in the issue report itself. This means setting a +reasonable expiry for those, until the issue is resolved or closed. + +If you know how to fix the issue, you can do so in your own fork & +branch, and submit a pull request. The issue report information above +should be part of that. + +If your issue report can describe the steps to reproduce the problem, +that is great. If you can include a unit test that reproduces the +problem, that is even better, as it gives whoever is fixing it a clearer +target! diff --git a/contributing/css.md b/contributing/css.md new file mode 100644 index 000000000000..3108319a0e1b --- /dev/null +++ b/contributing/css.md @@ -0,0 +1,42 @@ +# Contribution CSS + +CodeIgniter uses SASS to generate the debug toolbar's CSS. Therefore, +you will need to install it first. You can find further instructions on +the official website: + +## Compile SASS files + +Open your terminal, and navigate to CodeIgniter's root folder. To +generate the CSS file, use the following command: + +`sass --no-cache --sourcemap=none admin/css/debug-toolbar/toolbar.scss system/Debug/Toolbar/Views/toolbar.css` + +Details: +- `--no-cache` is a parameter defined to disable SASS cache, +this prevents a "cache" folder from being created +- `--sourcemap=none` is a parameter which prevents soucemap files from being generated +- `admin/css/debug-toolbar/toolbar.scss` is the SASS source +- `system/Debug/Toolbar/Views/toolbar.css` is he CSS destination + +## Color scheme + +**Themes** + +Dark: `#252525` / `rgb(37, 37, 37)` +Light: `#FFFFFF` / `rgb(255, 255, 255)` + +**Glossy colors** + +Blue: `#5BC0DE` / `rgb(91, 192, 222)` +Gray: `#434343` / `rgb(67, 67, 67)` +Green: `#9ACE25` / `rgb(154, 206, 37)` +Orange: `#DD8615` / `rgb(221, 134, 21)` +Red: `#DD4814` / `rgb(221, 72, 20)` + +**Matt colors** + +Blue: `#D8EAF0` / `rgb(216, 234, 240)` +Gray: `#DFDFDF` / `rgb(223, 223, 223)` +Green: `#DFF0D8` / `rgb(223, 240, 216)` +Orange: `#FDC894` / `rgb(253, 200, 148)` +Red: `#EF9090` / `rgb(239, 144, 144)` diff --git a/contributing/css.rst b/contributing/css.rst deleted file mode 100644 index d7b047e1695c..000000000000 --- a/contributing/css.rst +++ /dev/null @@ -1,45 +0,0 @@ -================ -Contribution CSS -================ - -CodeIgniter uses SASS to generate the debug toolbar's CSS. Therefore, you -will need to install it first. You can find further instructions on the -official website: https://sass-lang.com/install - -Compile SASS files -================== - -Open your terminal, and navigate to CodeIgniter's root folder. To generate -the CSS file, use the following command: ``sass --no-cache --sourcemap=none admin/css/debug-toolbar/toolbar.scss system/Debug/Toolbar/Views/toolbar.css`` - -Details: -- ``--no-cache`` is a parameter defined to disable SASS cache, this prevents - a "cache" folder from being created -- ``--sourcemap=none`` is a parameter which prevents soucemap files from - being generated -- ``admin/css/debug-toolbar/toolbar.scss`` is the SASS source -- ``system/Debug/Toolbar/Views/toolbar.css`` is he CSS destination - -Color scheme -============ - -**Themes** - -Dark: `#252525` / `rgb(37, 37, 37)` -Light: `#FFFFFF` / `rgb(255, 255, 255)` - -**Glossy colors** - -Blue: `#5BC0DE` / `rgb(91, 192, 222)` -Gray: `#434343` / `rgb(67, 67, 67)` -Green: `#9ACE25` / `rgb(154, 206, 37)` -Orange: `#DD8615` / `rgb(221, 134, 21)` -Red: `#DD4814` / `rgb(221, 72, 20)` - -**Matt colors** - -Blue: `#D8EAF0` / `rgb(216, 234, 240)` -Gray: `#DFDFDF` / `rgb(223, 223, 223)` -Green: `#DFF0D8` / `rgb(223, 240, 216)` -Orange: `#FDC894` / `rgb(253, 200, 148)` -Red: `#EF9090` / `rgb(239, 144, 144)` diff --git a/contributing/documentation.rst b/contributing/documentation.rst index bf7a56662f49..03164f183e6e 100644 --- a/contributing/documentation.rst +++ b/contributing/documentation.rst @@ -13,12 +13,12 @@ It is created automatically by inserting the following: :: - .. contents:: - :local: + .. contents:: + :local: - .. raw:: html + .. raw:: html -
+
.. contents:: :local: @@ -51,44 +51,44 @@ Headings are formed by using certain characters as underlines for a bit of text. Major headings, like page titles and section headings also use overlines. Other headings just use underlines, with the following hierarchy:: - # with overline for page titles - * with overline for major sections - = for subsections - - for subsubsections - ^ for subsubsubsections - " for subsubsubsubsections (!) + # with overline for page titles + * with overline for major sections + = for subsections + - for subsubsections + ^ for subsubsubsections + " for subsubsubsubsections (!) The :download:`TextMate ELDocs Bundle <./ELDocs.tmbundle.zip>` can help you create these with the following tab triggers:: - title-> + title-> - ########## - Page Title - ########## + ########## + Page Title + ########## - sec-> + sec-> - ************* - Major Section - ************* + ************* + Major Section + ************* - sub-> + sub-> - Subsection - ========== + Subsection + ========== - sss-> + sss-> - SubSubSection - ------------- + SubSubSection + ------------- - ssss-> + ssss-> - SubSubSubSection - ^^^^^^^^^^^^^^^^ + SubSubSubSection + ^^^^^^^^^^^^^^^^ - sssss-> + sssss-> - SubSubSubSubSection (!) - """"""""""""""""""""""" + SubSubSubSubSection (!) + """"""""""""""""""""""" diff --git a/contributing/guidelines.rst b/contributing/guidelines.rst deleted file mode 100644 index 1f42a920fa6b..000000000000 --- a/contributing/guidelines.rst +++ /dev/null @@ -1,99 +0,0 @@ -======================= -Contribution Guidelines -======================= - -Your Pull Requests (PRs) need to meet our guidelines. If a PR fails -to pass these guidelines, it will be declined and you will need to re-submit -when you’ve made the changes. This might sound a bit tough, but it is required -for us to maintain quality of the code-base. - -PHP Style -========= - -All code must conform to our `Style Guide -<./styleguide.rst>`_, which is -essentially the `Allman indent style -`_, with -elaboration on naming and readable operators. - -This makes certain that all code is the same format as the -existing code and means it will be as readable as possible. - -Our Style Guide is similar to PSR-1 and PSR-2, from PHP-FIG, -but not necessarily the same or compatible. - -Unit Testing -============ - -Unit testing is expected for all CodeIgniter components. -We use PHPunit, and run unit tests using travis-ci -for each PR submitted or changed. - -In the CodeIgniter project, there is a ``tests`` folder, with a structure that -parallels that of ``system``. - -The normal practice would be to have a unit test class for each of the classes -in ``system``, named appropriately. For instance, the ``BananaTest`` -class would test the ``Banana`` class. There will be occasions when -it is more convenient to have separate classes to test different functionality -of a single CodeIgniter component. - -See the `PHPUnit website `_ for more information. - -PHPdoc Comments -=============== - -Source code should be commented using PHPdoc comments blocks. -Thie means implementation comments to explain potentially confusing sections -of code, and documentation comments before each public or protected -class/interface/trait, method and variable. - -See the `phpDocumentor website `_ for more information. - -Documentation -============= - -The User Guide is an essential component of the CodeIgniter framework. - -Each framework component or group of components needs a corresponding -section in the User Guide. Some of the more fundamental components will -show up in more than one place. - -Change Log -========== - -The change-log, in the user guide root, needs to be kept up-to-date. -Not all changes will need an entry in it, but new classes, major or BC changes -to existing classes should. Once we have a stable release, bug fixes would -appear in the changelog too. - -The changelog is independently maintained by the framework release manager -Make sure that your PR descriptions help us decide if the contribution should -be highlighted in the next release after it has been merged. - -PHP Compatibility -================= - -CodeIgniter4 requires PHP 7.3. - -Backwards Compatibility -======================= - -Generally, we aim to maintain backwards compatibility between minor -versions of the framework. Any changes that break compatibility need -a good reason to do so, and need to be pointed out in the -`Upgrading `_ guide. - -CodeIgniter4 itself represents a significant backwards compatibility break -with earlier versions of the framework. - -Mergeability -============ - -Your PRs need to be mergeable and GPG-signed before they will be considered. - -We suggest that you synchronize your repository's ``develop`` branch with -that in the main repository, and then your feature branch and -your develop branch, before submitting a PR. -You will need to resolve any merge conflicts introduced by changes -incorporated since you started working on your contribution. diff --git a/contributing/internals.md b/contributing/internals.md new file mode 100644 index 000000000000..6ced5f56dc48 --- /dev/null +++ b/contributing/internals.md @@ -0,0 +1,154 @@ +# CodeIgniter Internals Overview + +This guide should help contributors understand how the core of the +framework works, and what needs to be done when creating new +functionality. Specifically, it details the information needed to create +new packages for the core. + +## Dependencies + +All packages should be designed to be completely isolated from the rest +of the packages, if possible. This will allow them to be used in +projects outside of CodeIgniter. Basically, this means that any +dependencies should be kept to a minimum. Any dependencies must be able +to be passed into the constructor. If you do need to use one of the +other core packages, you can create that in the constructor using the +`Services` class, as long as you provide a way for dependencies to +override that: + +```php + public function __construct(Foo $foo=null) + { + $this->foo = $foo instanceOf Foo + ? $foo + : \Config\Services::foo(); + } +``` + +## Type declarations + +PHP7 provides [Type declarations](https://www.php.net/manual/en/language.types.declarations.php) +for method parameters and return types. Use it where possible. Return type +declaration is not always practical, but do try to make it work. + +At this time, shipped CI4 production code does not use +[Strict typing](https://www.php.net/manual/en/language.types.declarations.php#language.types.declarations.strict), +and will not be any time soon. However, in the development phase, +there are internal classes (in `utils/`) that are strictly typed. + +## Abstractions + +The amount of abstraction required to implement a solution should be the +minimal amount required. Every layer of abstraction brings additional +levels of technical debt and unnecessary complexity. That said, don't be +afraid to use it when it's needed and can help things. + +- Don't create a new container class when an array will do just fine. +- Start simple, refactor as necessary to achieve clean separation of + code, but don't overdo it. + +## Testing + +Any new packages submitted to the framework must be accompanied by unit +tests. The target is 80%+ code coverage of all classes within the +package. + +- Test only public methods, not protected and private unless the + method really needs it due to complexity. +- Don't just test that the method works, but test for all fail states, + thrown exceptions, and other pathways through your code. + +You should be aware of the extra assertions that we have made, +provisions for accessing private properties for testing, and mock +services. We have also made a **CITestStreamFilter** to capture test +output. Do check out similar tests in `tests/system/`, and read the +"Testing" section in the user guide, before you dig in to your own. + +Some testing needs to be done in a separate process, in order to setup +the PHP globals to mimic test situations properly. See +`tests/system/HTTP/ResponseSendTest` for an example of this. + +## Namespaces and Files + +All new packages should live under the `CodeIgniter` namespace. The +package itself will need its own sub-namespace that collects all related +files into one grouping, like `CodeIgniter\HTTP`. + +Files MUST be named the same as the class they hold, and they must match +the Style Guide <./styleguide.md>, meaning CamelCase class and +file names. They should be in their own directory that matches the +sub-namespace under the **system** directory. + +Take the Router class as an example. The Router lives in the +`CodeIgniter\Router` namespace. Its two main classes, +**RouteCollection** and **Router**, are in the files +**system/Router/RouteCollection.php** and **system/Router/Router.php** +respectively. + +## Interfaces + +Most base classes should have an interface defined for them. At the very +least this allows them to be easily mocked and passed to other classes +as a dependency, without breaking the type-hinting. The interface names +should match the name of the class with "Interface" appended to it, like +`RouteCollectionInterface`. + +The Router package mentioned above includes the +`CodeIgniter\Router\RouteCollectionInterface` and +`CodeIgniter\Router\RouterInterface` interfaces to provide the +abstractions for the two classes in the package. + +## Handlers + +When a package supports multiple "drivers", the convention is to place +them in a **Handlers** directory, and name the child classes as +Handlers. You will often find that creating a `BaseHandler`, that the +child classes can extend, to be beneficial in keeping the code DRY. + +See the Log and Session packages for examples. + +## Configuration + +Should the package require user-configurable settings, you should create +a new file just for that package under **app/Config**. The file name +should generally match the package name. + +## Autoloader + +All files within the package should be added to +**system/Config/AutoloadConfig.php**, in the "classmap" property. This +is only used for core framework files, and helps to minimize file system +scans and keep performance high. + +## Command-Line Support + +CodeIgniter has never been known for it's strong CLI support. However, +if your package could benefit from it, create a new file under +**system/Commands**. The class contained within is simply a controller +that is intended for CLI usage only. The `index()` method should provide +a list of available commands provided by that package. + +Routes must be added to **system/Config/Routes.php** using the `cli()` +method to ensure it is not accessible through the browser, but is +restricted to the CLI only. + +See the **MigrationsCommand** file for an example. + +## Documentation + +All packages must contain appropriate documentation that matches the +tone and style of the rest of the user guide. In most cases, the top +portion of the package's page should be treated in tutorial fashion, +while the second half would be a class reference. + +## Modification of the `env` file + +CodeIgniter is shipped with a template `env` file to support adding +secrets too sensitive to be stored in a version control system. +Contributors adding new entries to the env file should always ensure +that these entries are commented, i.e., starting with a hash (`#`). This +is because we have spark commands that actually copy the template file +to a `.env` file (which is actually the live version actually read by +CodeIgniter for secrets) if the latter is missing. As much as possible, +we do not want settings to go live unexpectedly without the user's +knowledge. diff --git a/contributing/internals.rst b/contributing/internals.rst deleted file mode 100644 index ba7f908f88e9..000000000000 --- a/contributing/internals.rst +++ /dev/null @@ -1,148 +0,0 @@ -############################## -CodeIgniter Internals Overview -############################## - -This guide should help contributors understand how the core of the framework works, -and what needs to be done when creating new functionality. Specifically, it -details the information needed to create new packages for the core. - -Dependencies -============ - -All packages should be designed to be completely isolated from the rest of the -packages, if possible. This will allow them to be used in projects outside of CodeIgniter. -Basically, this means that any dependencies should be kept to a minimum. -Any dependencies must be able to be passed into the constructor. If you do need to use one -of the other core packages, you can create that in the constructor using the -Services class, as long as you provide a way for dependencies to override that:: - - public function __construct(Foo $foo=null) - { - $this->foo = $foo instanceOf Foo - ? $foo - : \Config\Services::foo(); - } - -Type hinting -============ - -PHP7 provides the ability to `type hint `_ -method parameters and return types. Use it where possible. Return type hinting -is not always practical, but do try to make it work. - -At this time, we are not using strict type hinting. - -Abstractions -============ - -The amount of abstraction required to implement a solution should be the minimal -amount required. Every layer of abstraction brings additional levels of technical -debt and unnecessary complexity. That said, don't be afraid to use it when it's -needed and can help things. - -* Don't create a new container class when an array will do just fine. -* Start simple, refactor as necessary to achieve clean separation of code, but don't overdo it. - -Testing -======= - -Any new packages submitted to the framework must be accompanied by unit tests. -The target is 80%+ code coverage of all classes within the package. - -* Test only public methods, not protected and private unless the method really needs it due to complexity. -* Don't just test that the method works, but test for all fail states, thrown exceptions, and other pathways through your code. - -You should be aware of the extra assertions that we have made, provisions for -accessing private properties for testing, and mock services. -We have also made a **CITestStreamFilter** to capture test output. -Do check out similar tests in ``tests/system/``, and read the "Testing" section -in the user guide, before you dig in to your own. - -Some testing needs to be done in a separate process, in order to setup the -PHP globals to mimic test situations properly. See -``tests/system/HTTP/ResponseSendTest`` for an example of this. - -Namespaces and Files -==================== - -All new packages should live under the ``CodeIgniter`` namespace. -The package itself will need its own sub-namespace -that collects all related files into one grouping, like ``CodeIgniter\HTTP``. - -Files MUST be named the same as the class they hold, and they must match the -:doc:`Style Guide <./styleguide.rst>`, meaning CamelCase class and file names. -They should be in their own directory that matches the sub-namespace under the -**system** directory. - -Take the Router class as an example. The Router lives in the ``CodeIgniter\Router`` -namespace. Its two main classes, -**RouteCollection** and **Router**, are in the files **system/Router/RouteCollection.php** and -**system/Router/Router.php** respectively. - -Interfaces ----------- - -Most base classes should have an interface defined for them. -At the very least this allows them to be easily mocked -and passed to other classes as a dependency, without breaking the type-hinting. -The interface names should match the name of the class with "Interface" appended -to it, like ``RouteCollectionInterface``. - -The Router package mentioned above includes the -``CodeIgniter\Router\RouteCollectionInterface`` and ``CodeIgniter\Router\RouterInterface`` -interfaces to provide the abstractions for the two classes in the package. - -Handlers --------- - -When a package supports multiple "drivers", the convention is to place them in -a **Handlers** directory, and name the child classes as Handlers. -You will often find that creating a ``BaseHandler``, that the child classes can -extend, to be beneficial in keeping the code DRY. - -See the Log and Session packages for examples. - -Configuration -============= - -Should the package require user-configurable settings, you should create a new -file just for that package under **app/Config**. -The file name should generally match the package name. - -Autoloader -========== - -All files within the package should be added to **system/Config/AutoloadConfig.php**, -in the "classmap" property. This is only used for core framework files, and helps -to minimize file system scans and keep performance high. - -Command-Line Support -==================== - -CodeIgniter has never been known for it's strong CLI support. However, if your -package could benefit from it, create a new file under **system/Commands**. -The class contained within is simply a controller that is intended for CLI -usage only. The ``index()`` method should provide a list of available commands -provided by that package. - -Routes must be added to **system/Config/Routes.php** using the ``cli()`` method -to ensure it is not accessible through the browser, but is restricted to the CLI only. - -See the **MigrationsCommand** file for an example. - -Documentation -============= - -All packages must contain appropriate documentation that matches the tone and -style of the rest of the user guide. In most cases, the top portion of the package's -page should be treated in tutorial fashion, while the second half would be a class reference. - -Modification of the ``env`` file -================================ - -CodeIgniter is shipped with a template ``env`` file to support adding secrets too sensitive to -be stored in a version control system. Contributors adding new entries to the env file should -always ensure that these entries are commented, i.e., starting with a hash (``#``). This is -because we have spark commands that actually copy the template file to a ``.env`` file (which -is actually the live version actually read by CodeIgniter for secrets) if the latter is missing. -As much as possible, we do not want settings to go live unexpectedly without the user's knowledge. diff --git a/contributing/pull_request.md b/contributing/pull_request.md new file mode 100644 index 000000000000..e08a5c110558 --- /dev/null +++ b/contributing/pull_request.md @@ -0,0 +1,241 @@ +# Sending a Pull Request + +## Contributions + +We expect all contributions to conform to our +[style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/styleguide.md), +be commented (inside the PHP source files), be documented (in the +[user guide](https://codeigniter4.github.io/userguide/)), and unit tested (in +the [test folder](https://github.com/codeigniter4/CodeIgniter4/tree/develop/tests)). + +Note, we expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite +to prove the code works. If pull requests are not accompanied by relevant tests, they will likely be closed. +Since we are a team of volunteers, we don't have any more time to work on the framework than you do. Please +make it as painless for your contributions to be included as possible. If you need help with getting tests +running on your local machines, ask for help on the forums. We would be happy to help out. + +The [Open Source Guide](https://opensource.guide/) is a good first read for those new to contributing to open source! + +## CodeIgniter Internals Overview + +[CodeIgniter Internals Overview](./internals.md) should help contributors +understand how the core of the framework works. Specifically, it details the +information needed to create new packages for the core. + +## Guidelines + +Before we look into how to contribute to CodeIgniter4, here are some guidelines. +Your Pull Requests (PRs) need to meet our guidelines. + +If your Pull Requests fail to pass these guidelines, they will be declined, +and you will need to re-submit when you’ve made the changes. +This might sound a bit tough, but it is required for us to maintain the quality of the codebase. + +### PHP Style + +- [CodeIgniter Coding Style Guide](./styleguide.md) + +All code must conform to our [Style Guide](./styleguide.md), which is +based on PSR-12. + +This makes certain that all submitted code is of the same format +as the existing code and ensures that the codebase will be as readable as possible. + +You can fix most of the coding style violations by running this command in your terminal: + + composer cs-fix + +You can check the coding style violations: + + composer cs + +### Unit Testing + +Unit testing is expected for all CodeIgniter components. We use PHPUnit, +and run unit tests using GitHub Actions for each PR submitted or changed. + +In the CodeIgniter project, there is a `tests` folder, with a structure +that parallels that of `system`. + +The normal practice would be to have a unit test class for each of the +classes in `system`, named appropriately. For instance, the `BananaTest` +class would test the `Banana` class. There will be occasions when it is +more convenient to have separate classes to test different functionality +of a single CodeIgniter component. + +See [Running System Tests](../tests/README.md) +and the [PHPUnit website](https://phpunit.de/) for more information. + +### Comments + +#### PHPDoc Comments + +Source code should be commented using PHPDoc comment blocks. This means +implementation comments to explain potentially confusing sections of +code, and documentation comments before each public or protected +class/interface/trait, method and variable. + +Do not add PHPDoc comments that are superficial, duplicated, or stating the obvious. + +See the [phpDocumentor website](https://phpdoc.org/) for more +information. + +#### Code Comments + +Do not add comments that are superficial, duplicated, or stating the obvious. + +### Documentation + +The User Guide is an essential component of the CodeIgniter framework. + +Each framework component or group of components needs a corresponding +section in the User Guide. Some of the more fundamental components will +show up in more than one place. + +If you change anything that requires a change to documentation, +then you will need to add to the documentation. +New classes, methods, parameters, changing default values, etc. +are all changes that require a change to documentation. +Also, the [changelog](https://codeigniter4.github.io/CodeIgniter4/changelogs/index.html) must be updated for every change, +and [PHPDoc](https://github.com/codeigniter4/CodeIgniter4/blob/develop/phpdoc.dist.xml) blocks must be maintained. + +See [Writing CodeIgniter Documentation](./documentation.rst). + +### Changelog + +The changelog, in the user guide root, needs to be kept up-to-date. Not +all changes will need an entry in it, but new classes, major or BC +changes to existing classes should. Once we have a stable release, bug +fixes would appear in the changelog too. + +The changelog is independently maintained by the framework release +manager Make sure that your PR descriptions help us decide if the +contribution should be highlighted in the next release after it has been +merged. + +### CSS + +See [Contribution CSS](./css.md). + +### Compatibility + +CodeIgniter4 requires [PHP 7.3](https://php.net/releases/7_3_0.php). + +### Backwards Compatibility + +Generally, we aim to maintain backwards compatibility between minor +versions of the framework. Any changes that break compatibility need a +good reason to do so, and need to be pointed out in the +[Upgrading](https://codeigniter4.github.io/userguide/installation/upgrading.html) +guide. + +CodeIgniter4 itself represents a significant backwards compatibility +break with earlier versions of the framework. + +#### Breaking Changes + +In general, any change that would disrupt existing uses of the framework is considered a "breaking change" and will not be favorably considered. A few specific examples to pay attention to: + +1. New classes/properties/constants in `system` are acceptable, but anything in the `app` directory that will be used in `system` should be backwards-compatible. +2. Any changes to non-private methods must be backwards-compatible with the original definition. +3. Deleting non-private properties or methods without prior deprecation notices is frowned upon and will likely be closed. +4. Deleting or renaming public classes and interfaces, as well as those not marked as `@internal`, without prior deprecation notices or not providing fallback solutions will also not be favorably considered. + +### Mergeability + +Your PRs need to be mergeable and GPG-signed before they will be +considered. + +We suggest that you synchronize your repository's `develop` branch with +that in the main repository, and then your feature branch and your +develop branch, before submitting a PR. You will need to resolve any +merge conflicts introduced by changes incorporated since you started +working on your contribution. + +### Branching + +CodeIgniter4 uses the [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) branching model +which requires all Pull Requests to be sent to the __"develop"__ branch; this is where the next planned version will be developed. + +The __"master"__ branch will always contain the latest stable version and is kept clean so a "hotfix" (e.g. an +emergency security patch) can be applied to the "master" branch to create a new version, without worrying +about other features holding it up. For this reason, all commits need to be made to the "develop" branch, +and any sent to the "master" branch will be closed automatically. If you have multiple changes to submit, +please place all changes into their own branch on your fork. + +**One thing at a time:** A pull request should only contain one change. That does not mean only one commit, +but one change - however many commits it took. The reason for this is that if you change X and Y, +but send a pull request for both at the same time, we might really want X but disagree with Y, +meaning we cannot merge the request. Using the Git-Flow branching model you can create new +branches for both of these features and send two requests. + +A reminder: **please use separate branches for each of your PRs** - it will make it easier for you to keep +changes separate from each other and from whatever else you are doing with your repository! + +### Signing + +You must [GPG-sign](./signing.md) your work, certifying that you either wrote the work or +otherwise have the right to pass it on to an open-source project. See [Developer's Certificate of Origin](./DCO.md). + +This is *not* just a "signed-off-by" commit, but instead, a digitally signed one. + +See [Contribution signing](./signing.md) for details. + +### Static Analysis on PHP code + +We cannot, at all times, guarantee that all PHP code submitted on pull requests to be working well without +actually running the code. For this reason, we make use of two static analysis tools, [PHPStan][1] +and [Rector][2] to do the analysis for us. + +These tools have already been integrated into our CI/CD workflow to minimize unannounced bugs. Pull requests +are expected that their code will pass these two. In your local machine, you can manually run these tools +so that you can fix whatever errors that pop up with your submission. + +PHPStan is expected to scan the entire framework by running this command in your terminal: + + vendor/bin/phpstan analyse + +Rector, on the other hand, can be run on the specific files you modified or added: + + vendor/bin/rector process --dry-run path/to/file + +If you run it without `--dry-run`, Rector will fix the code: + + vendor/bin/rector process path/to/file + +[1]: https://github.com/phpstan/phpstan-src +[2]: https://github.com/rector/rector + +## How-to Guide + +The best way to contribute is to fork the CodeIgniter4 repository, and "clone" that to your development area. That sounds like some jargon, but "forking" on GitHub means "making a copy of that repo to your account" and "cloning" means "copying that code to your environment so you can work on it". + +1. Set up Git ([Windows](https://git-scm.com/download/win), [Mac](https://git-scm.com/download/mac), & [Linux](https://git-scm.com/download/linux)). +2. Go to the [CodeIgniter4 repository](https://github.com/codeigniter4/CodeIgniter4). +3. [Fork](https://help.github.com/en/articles/fork-a-repo) it (to your GitHub account). +4. [Clone](https://help.github.com/en/articles/cloning-a-repository) your CodeIgniter repository: `git@github.com:/CodeIgniter4.git` +5. Create a new [branch](https://help.github.com/en/articles/about-branches) in your project for each set of changes you want to make. +6. Fix existing bugs on the [Issue tracker](https://github.com/codeigniter4/CodeIgniter4/issues) after confirming that no one else is working on them. +7. [Commit](https://help.github.com/en/desktop/contributing-to-projects/committing-and-reviewing-changes-to-your-project) the changed files in your contribution branch. +8. Commit messages are expected to be descriptive of what you changed specifically. Commit messages like + "Fixes #1234" would be asked by the reviewer to be revised. +9. If there are intermediate commits that are not meaningful to the overall PR, such as "Fixed error on style guide", "Fixed phpstan error", "Fixing mistake in code", and other related commits, it is advised to squash your commits so that we can have a clean commit history. +10. If you have touched PHP code, run static analysis. +11. Run unit tests on the specific file you modified. If there are no existing tests yet, please create one. +12. Make sure the tests pass to have a higher chance of merging. +13. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork. +14. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). + +See [Contribution workflow](./workflow.md) for Git workflow details. + +The codebase maintainers will now be alerted to the submission and someone from the team will respond. If your change fails to meet the guidelines, it will be rejected or feedback will be provided to help you improve it. + +Once the maintainer handling your pull request is satisfied with it, they will approve the pull request and merge it into the "develop" branch. Your patch will now be part of the next release! + +## Translating System Messages + +If you wish to contribute to the system message translations, +then fork and clone the [translations repository](https://github.com/codeigniter4/translations) +separately from the codebase. + +These are two independent repositories! diff --git a/contributing/signing.md b/contributing/signing.md new file mode 100644 index 000000000000..ca84ef79a9a7 --- /dev/null +++ b/contributing/signing.md @@ -0,0 +1,63 @@ +# Contribution Signing + +We ask that contributions have code commits signed. **This is important +in order to prove, as best we can, the provenance of contributions.** + +The developer pushing a commit as part of a PR isn't necessarily the +person who committed it originally, if the commit is not signed. This +distorts the commit history and makes it hard to tell where code came +from. + +If a person "signs off" a commit, they are free to use any name, +specifically one not their own. Again, the commit history cannot be +relied on to determine the origin of the code, if one developer is +spoofing another. A malicious person could commit bad code (for instance +a virus) and make it look like another developer created it. + +The best solution, while not fool-proof, is to "securely sign" your +commits. Such commits are digitally signed, with a GPG-key associated +with your GitHub account. It still isn't foolproof, because a malicious +developer could create a bogus email and account, but it is more +reliable than an unsigned or a "signed-off by" commit. + +If you don't sign your commits, we **may** accept your contribution, +assuming it meets usefulness and contribution guidelines, but only if it +isn't critical code and only after checking it carefully. If code +performs an important role, we will insist that it be securely signed. + +Read below to find out how to sign your commits :) + +## Secure Signing + +To verify your commits, you will need to setup a GPG key, and attach it +to your GitHub account. + +See the [git tools](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) page +for directions on doing this. The complete story is part of [GitHub help](https://help.github.com/categories/gpg/). + +The basic steps are + +- [generate your GPG key](https://help.github.com/articles/generating-a-new-gpg-key/), + and copy the ASCII representation of it. +- [Add your GPG key to your GitHub account](https://help.github.com/articles/adding-a-new-gpg-key-to-your-github-account/). +- [Tell Git](https://help.github.com/articles/telling-git-about-your-gpg-key/) + about your GPG key. +- [Set default signing](https://help.github.com/articles/signing-commits-using-gpg/) + to have all of your commits securely signed automatically. +- Provide your GPG key passphrase, as prompted, when you do a commit. + +Depending on your IDE, you may have to do your Git commits from your Git +bash shell to use the **-S** option to force the secure signing. + +## Commit Messages + +Regardless of how you sign a commit, commit messages are important too. +They communicate the intent of a specific change, concisely. They make +it easier to review code, and to find out why a change was made if the +code history is examined later. + +The audience for your commit messages will be the codebase maintainers, +any code reviewers, and debuggers trying to figure out when a bug might +have been introduced. + +Make your commit messages meaningful. diff --git a/contributing/signing.rst b/contributing/signing.rst deleted file mode 100644 index 8d74c1a5aae6..000000000000 --- a/contributing/signing.rst +++ /dev/null @@ -1,65 +0,0 @@ -==================== -Contribution Signing -==================== - -We ask that contributions have code commits signed. **This is important in order -to prove, as best we can, the provenance of contributions.** - -The developer pushing a commit as part of a PR isn't necessarily the person -who committed it originally, if the commit is not signed. This distorts the -commit history and makes it hard to tell where code came from. - -If a person "signs off" a commit, they are free to use any name, specifically -one not their own. Again, the commit history cannot be relied on to determine -the origin of the code, if one developer is spoofing another. A malicious person -could commit bad code (for instance a virus) and make it look like another -developer created it. - -The best solution, while not fool-proof, is to "securely sign" your -commits. Such commits are digitally signed, with a GPG-key -associated with your github account. It still isn't foolproof, because -a malicious developer could create a bogus email and account, but it is -more reliable than an unsigned or a "signed-off by" commit. - -If you don't sign your commits, we **may** accept your contribution, -assuming it meets usefulness and contribution guidelines, but only -if it isn't critical code and only after checking it carefully. -If code performs an important role, we will insist that it be securely signed. - -Read below to find out how to sign your commits :) - - -Secure Signing -============== - -To verify your commits, you will need to -setup a GPG key, and attach it to your github account. - -See the `git tools `_ -page for directions on doing this. The complete story is part of -`Github help `_. - -The basic steps are - -- `generate your GPG key `_, and copy the ASCII representation of it. -- `Add your GPG key to your Github account `_. -- `Tell Git `_ about your GPG key. -- `Set default signing `_ to have all of your commits securely signed automatically. -- Provide your GPG key passphrase, as prompted, when you do a commit. - -Depending on your IDE, you may have to do your Git commits from your Git bash shell -to use the **-S** option to force the secure signing. - -Commit Messages -=============== - -Regardless of how you sign a commit, commit messages are important too. -They communicate the intent of a specific change, concisely. -They make it easier to review code, and to find out why a change was made -if the code history is examined later. - -The audience for your commit messages will be the codebase maintainers, any -code reviewers, and debuggers trying to figure out when a bug might have been -introduced. - -Make your commit messages meaningful. diff --git a/contributing/styleguide.md b/contributing/styleguide.md new file mode 100644 index 000000000000..1b8b499048a2 --- /dev/null +++ b/contributing/styleguide.md @@ -0,0 +1,260 @@ +# CodeIgniter Coding Style Guide + +This document declares a set of coding conventions and rules to be followed when contributing PHP code +to the CodeIgniter project. + +**Note:** +> While we would recommend it, there's no requirement that you follow these conventions and rules in your +own projects. Usage is discretionary within your projects but strictly enforceable within the framework. + +We follow the [PSR-12: Extended Coding Style][psr12] plus a set of our own +styling conventions. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", +"MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](http://tools.ietf.org/html/rfc2119). + +_Portions of the following rules are from and attributed to [PSR-12][psr12]. Even if we do not copy all the rules to this coding style guide explicitly, such uncopied rules SHALL still apply._ + +[psr12]: https://www.php-fig.org/psr/psr-12/ + +## Implementation + +Our team uses [PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer) to apply coding standard fixes automatically. If you would like to leverage these tools yourself visit the [Official CodeIgniter Coding Standard](https://github.com/CodeIgniter/coding-standard) repository for details. + +## General + +### Files + +- All PHP files MUST use the Unix LF (linefeed) line ending only. +- All PHP files MUST end with a non-blank line, terminated with a single LF. +- The closing `?>` tag MUST be omitted from files containing only PHP. + +### Lines + +- There MUST NOT be a hard limit on line length. +- The soft limit on line length MUST be 120 characters. +- Lines SHOULD NOT be longer than 80 characters; lines longer than that SHOULD be split into multiple subsequent lines of no more than 80 characters each. +- There MUST NOT be trailing whitespace at the end of lines. +- Blank lines MAY be added to improve readability and to indicate related blocks of code except where explicitly forbidden. +- There MUST NOT be more than one statement per line. + +### Indenting + +- Code MUST use an indent of 4 spaces for each indent level, and MUST NOT use tabs for indenting. + +### Keywords and Types + +- All PHP reserved [keywords][1] and [types][2] MUST be in lower case. +- Any new types and keywords added to future PHP versions MUST be in lower case. +- Short form of type keywords MUST be used i.e. `bool` instead of `boolean`, `int` instead of `integer` etc. + +[1]: http://php.net/manual/en/reserved.keywords.php +[2]: http://php.net/manual/en/reserved.other-reserved-words.php + +## Declare Statements, Namespace, and Import Statements + +The header of a PHP file may consist of a number of different blocks. If present, each of the blocks below +MUST be separated by a single blank line, and MUST NOT contain a blank line. Each block MUST be in the order +listed below, although blocks that are not relevant may be omitted. + +- Opening ` All the preceding rules are quoted from PSR-12. You may visit its website to view the code block samples. + +## Custom Conventions + +### File Naming + +- Files containing PHP code SHOULD end with a ".php" extension. +- Files containing templates SHOULD end with a ".tpl" extension. +- Files containing classes, interfaces, or traits MUST have their base name exactly matching the name +of the classes they declare. +- Files declaring procedural functions SHOULD be written in snake_case format. + +### Naming of Structural Elements + +- Constants MUST be declared in UPPERCASE_SEPARATED_WITH_UNDERSCORES. +- Class names MUST be declared in PascalCase. +- Method and property names MUST be declared in camelCase. +- Procedural functions MUST be in snake_case. +- Abbreviations/acronyms/initialisms SHOULD be written in their own natural format. + +### Logical Operators + +- The negation operator `!` SHOULD have one space from its argument. +```diff +-!$result ++! $result +``` + +- Use parentheses to clarify potentially confusing logical expressions. + +### PHP Docblocks (PHPDoc) + +- There SHOULD be no useless PHPDoc annotations. +```diff +-/** +- * @param string $data Data +- * @return void +- */ + public function analyse(string $data): void {}; +``` + +### PHPUnit Assertions + +- As much as possible, you SHOULD always use the strict version of assertions. +```diff +-$this->assertEquals(12, (int) $axis); ++$this->assertSame(12, (int) $axis); +``` + +- Use the dedicated assertion instead of using internal types. +```diff +-$this->assertSame(true, is_cli()); ++$this->assertTrue(is_cli()); + +-$this->assertTrue(array_key_exists('foo', $array)); ++$this->assertArrayHasKey('foo', $array); +``` diff --git a/contributing/styleguide.rst b/contributing/styleguide.rst deleted file mode 100644 index a500d663fe11..000000000000 --- a/contributing/styleguide.rst +++ /dev/null @@ -1,327 +0,0 @@ -###################### -PHP Coding Style Guide -###################### - -The following document declares a set of coding convention rules to be -followed when contributing PHP code to the CodeIgniter project. - -Some of these rules, like naming conventions for example, *may* be -incorporated into the framework's logic and therefore be functionally -enforced (which would be separately documented), but while we would -recommend it, there's no requirement that you follow these conventions in -your own applications. - -The `PHP Interop Group `_ has proposed a number of -canonical recommendations for PHP code style. CodeIgniter is not a member of -of PHP-FIG. We commend their efforts to unite the PHP community, -but no not agree with all of their recommendations. - -PSR-2 is PHP-FIG's Coding Style Guide. We do not claim conformance with it, -although there are a lot of similarities. The differences will be pointed out -below. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", -"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to -be interpreted as described in `RFC 2119 `_. - -*Note: When used below, the term "class" refers to all kinds of classes, -interfaces and traits.* - -***** -Files -***** - -Formatting -========== - -- Files MUST use UTF-8 character set encoding without BOM. -- Files MUST use UNIX line endings (LF: `\n`). -- Files MUST end with a single empty line (i.e. LF: `\n`). - -Structure -========= - -- A single file SHOULD NOT declare more than one class. - Examples where we feel that more than one class in a source file - is appropriate: - - - `system/Debug/CustomExceptions` contains a number of CodeIgniter - exceptions and errors, that we want to use for a consistent - experience across applications. - If we stick with the purist route, then each of the 13+/- custom - exceptions would require an additional file, which would have a - performance impact at times. - - `system/HTTP/Response` provides a RedirectException, used with the - Response class. - - `system/Router/Router` similarly provides a RedirectException, used with - the Router class. - -- Files SHOULD either declare symbols (i.e. classes, functions, constants) - or execute non-declarative logic, but SHOULD NOT do both. - -Naming -====== - -- File names MUST end with a ".php" name extension and MUST NOT have - multiple name extensions. -- Files declaring classes, interfaces or traits MUST have names exactly matching - the classes that they declare (obviously excluding the ".php" name extension). -- Files declaring functions SHOULD be named in *snake_case.php*. - -************************************* -Whitespace, indentation and alignment -************************************* - -- Best practice: indentation SHOULD use only tabs. -- Best practice: alignment SHOULD use only spaces. -- If using tabs for anything, you MUST set the tab spacing to 4. - -This will accommodate the widest range of developer environment options, -while maintaining consistency of code appearance. - -Following the "best practice" above, -the following code block would have a single tab at the beginning of -each line containing braces, and two tabs at the beginning of the -nested statements. No alignment is implied:: - - { - $first = 1; - $second = 2; - $third = 3; - } - -Following the "best practice" above, -the following code block would use spaces to have the assignment -operators line up with each other:: - - { - $first = 1; - $second = 2; - $third = 3; - } - -.. note:: Our indenting and alignment convention differs from PSR-2, which - **only** uses spaces for both indenting and alignment. - -- Unnecessary whitespace characters MUST NOT be present anywhere within a - script. - - That includes trailing whitespace after a line of code, two or - more spaces used when only one is necessary (excluding alignment), as - well as any other whitespace usage that is not functionally required or - explicitly described in this document. - -.. note:: With conforming tab settings, alignment spacing should - be preserved in all development environments. - A pull request that deals only with tabs or spaces for alignment - will not be favorably considered. - -**** -Code -**** - -PHP tags -======== - -- Opening tags MUST only use the `` tags SHOULD NOT be used, unless the intention is to start - direct output. - - - Scripts that don't produce output MUST NOT use the closing `?>` tag. - -Namespaces and classes -====================== - -- Class names and namespaces SHOULD be declared in `UpperCamelCase`, - also called `StudlyCaps`, unless - another form is *functionally* required. - - - Abbreviations in namespaces, class names and method names SHOULD be - written in capital letters (e.g. PHP). - -- Class constants MUST be declared in `CAPITALS_SEPARATED_BY_UNDERSCORES`. -- Class methods, property names and other variables MUST be declared in - `lowerCamelCase()`. -- Class methods and properties MUST have visibility declarations (i.e. - `public`, `private` or `protected`). - -Methods -------- - -To maintain consistency between core classes, class properties MUST -be private or protected, and the following public methods -MUST be used for each such property "x" - -- `getX()` when the method returns returns a property value, or null if not set -- `setX(value)` changes a property value, doesn't return anything, and can - throw exceptions -- `hasX()` returns a boolean to if a property exists -- `newX()` creates an instance of a/the component object and returns it, - and can throw exceptions -- `isX()` returns true/false for boolean properties - -- Methods SHOULD use type hints and return type hints - -Procedural code -=============== - -- Function and variable names SHOULD be declared in `snake_case()` (all - lowercase letters, separated by underscores), unless another form is - *functionally* required. -- Constants MUST be declared in `CAPITALS_SEPARATED_BY_UNDERSCORES`. - -Keywords -======== - -- All keywords MUST be written in lowercase letters. This includes "scalar" - types, but does NOT include core PHP classes such as `stdClass` or - `Exception`. -- Adjacent keywords are separated by a single space character. -- The keywords `require`, `require_once`, `include`, `include_once` MUST - be followed by a single space character and MUST NOT be followed by a - parenthesis anywhere within the declaration. -- The `function` keyword MUST be immediately followed by either an opening - parenthesis or a single space and a function name. -- Other keywords not explicitly mentioned in this section MUST be separated - by a single space character from any printable characters around them and - on the same line. - -Operators -========= - -- The single dot concatenation, incrementing, decrementing, error - suppression operators and references MUST NOT be separated from their - subjects. -- Other operators not explicitly mentioned in this section MUST be - separated by a single space character from any printable characters - around them and on the same line. -- An operator MUST NOT be the last set of printable characters on a line. -- An operator MAY be the first set of printable characters on a line. - -Logical Operators -================= - -- Use the symbol versions (**||** and **&&**) of the logical operators - instead of the word versions (**OR** and **AND**). - - - This is consistent with other programming languages - - It avoids the problem of the assignment operator (**=**) having - higher precedence:: - - $result = true && false; // $result is false, expected - $result = true AND false; // $result is true, evaluated as "($result = true) AND false" - $result = (true AND false); // $result is false - -- The logical negation operator MUST be separated from its argument by a - single space, as in **! $result** instead of **!$result** -- If there is potential confusion with a logical expression, then use - parentheses for clarity, as shown above. - -Control Structures -================== - -- Control structures, such as **if/else** statements, **for/foreach** statements, or - **while/do** statements, MUST use a brace-surrounded block for their body - segments. - - Good control structure examples:: - - if ( $foo ) - { - $bar += $baz; - } - else - { - $baz = 'bar'; - } - - Not-acceptable control structures:: - - if ( $foo ) $bar = $oneThing + $anotherThing + $yetAnotherThing + $evenMore; - - if ( $foo ) $bar += $baz; - else $baz = 'bar'; - -Docblocks -========= - -We use phpDocumentor (phpdoc) to generate the API docs, for all of the source -code inside the `system` folder. - -It wants to see a file summary docblock at the top of a PHP file, -before any PHP statements, and then a docblock before each documentable -component, namely any class/interface/trait, and all public and protected -methods/functions/variables. The docblock for a method or function -is expected to describe the parameters, return value, and any exceptions -thrown. - -Deviations from the above are considered errors by phpdoc. - -An example:: - - group = 'Unknown'; - } - - /** - * Model a fruit ripening over time. - * - * @param array $params - */ - abstract public function ripen(array $params); - } - -Other -===== - -- Argument separators (comma: `,`) MUST NOT be preceded by a whitespace - character and MUST be followed by a space character or a newline - (LF: `\n`). -- Semi-colons (i.e. `;`) MUST NOT be preceded by a whitespace character - and MUST be followed by a newline (LF: `\n`). - -- Opening parentheses SHOULD NOT be followed by a space character. -- Closing parentheses SHOULD NOT be preceded by a space character. - -- Opening square brackets SHOULD NOT be followed by a space character, - unless when using the "short array" declaration syntax. -- Closing square brackets SHOULD NOT be preceded by a space character, - unless when using the "short array" declaration syntax. - -- A curly brace SHOULD be the only printable character on a line, unless: - - - When declaring an anonymous function. - - Inside a "variable variable" (i.e. `${$foo}` or `${'foo'.$bar}`). - - Around a variable in a double-quoted string (i.e. `"Foo {$bar}"`). - -.. note:: Our control structures braces convention differs from PSR-2. - We use "Allman style" notation instead. diff --git a/contributing/workflow.md b/contributing/workflow.md new file mode 100644 index 000000000000..670727a89653 --- /dev/null +++ b/contributing/workflow.md @@ -0,0 +1,209 @@ +# Contribution Workflow + +Much of the workflow for contributing to CodeIgniter (or any project) +involves understanding how [Git](https://git-scm.com/) is used to manage +a shared repository and contributions to it. Examples below use the Git +bash shell, to be as platform neutral as possible. Your IDE may make +some of these easier. + +Some conventions used below, which you will need to provide appropriate +values for when you try these: + + ALL_PROJECTS // folder location with all your projects in subfolders, eg /lampp/htdocs + YOUR_PROJECT // folder containing the project you are working on, inside ALL_PROJECTS + ORIGIN_URL // the cloning URL for your repository fork + UPSTREAM_URL // the cloning URL for the CodeIgniter4 repository + +## Branching + +CodeIgniter uses the +[Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) +branching model, which requires all pull requests to be sent to the +"develop" branch. This is where the next planned version will be +developed. The "master" branch will always contain the latest stable +version and is kept clean so a "hotfix" (e.g: an emergency security +patch) can be applied to master to create a new version, without +worrying about other features holding it up. For this reason, all +commits need to be made to "develop" and any sent to "master" will be +closed automatically. If you have multiple changes to submit, please +place each change into their own branch on your fork. + +One thing at a time: a pull request should only contain one change. That +does not mean only one commit, but one change - however many commits it +took. The reason for this is that if you change X and Y but send a +single pull request for both at the same time, we might really want X +but disagree with Y, meaning we cannot merge the request. Using the +Git-Flow branching model you can create new branches for both of these +features and send two requests. + +## Forking + +You work with a fork of the CodeIgniter4 repository. This is a copy of +our repository, in your GitHub account. You can make changes to your +forked repository, while you cannot do the same with the shared one - +you have to submit pull requests to it instead. + +[Creating a fork](https://help.github.com/articles/fork-a-repo/) is done +through the GitHub website. Navigate to [our +repository](https://github.com/codeigniter4/CodeIgniter4), click the +**Fork** button in the top-right of the page, and choose which account +or organization of yours should contain that fork. + +## Cloning + +You *could* work on your repository using GitHub's web interface, but +that is awkward. Most developers will clone their repository to their +local system, and work with it there. + +On GitHub, navigate to your forked repository, click **Clone or +download**, and copy the cloning URL shown. We will refer to this as +ORIGIN\_URL. + +Clone your repository, leaving a local folder for you to work with: + + cd ALL_PROJECTS + git clone ORIGIN_URL + +## Syncing develop + +Within your local repository, Git will have created an alias, +**origin**, for the GitHub repository it is bound to. You want to create +an alias for the shared repository as well, so that you can "synch" the +two, making sure that your repository includes any other contributions +that have been merged by us into the shared repo: + + git remote add upstream UPSTREAM_URL + +Then synchronizing is done by pulling from us and pushing to you. This +is normally done locally, so that you can resolve any merge conflicts. +For instance, to synchronize **develop** branches: + + git checkout develop + git fetch upstream + git merge upstream/develop + git push origin develop + +You might get merge conflicts when you merge. It is your +responsibility to resolve those locally, so that you can continue +collaborating with the shared repository. Basically, the shared +repository is updated in the order that contributions are merged into +it, not in the order that they might have been submitted. If two PRs +update the same piece of code, then the first one to be merged will take +precedence, even if it causes problems for other contributions. + +It is a good idea to synchronize repositories when the shared one +changes. + +## Branching Revisited + +The top of this page talked about the **master** and **develop** +branches. The *best practice* for your work is to create a *feature +branch* locally, to hold a group of related changes (source, unit +testing, documentation, changelog, etc). + +This local branch should be named appropriately, for instance +"fix/problem123" or "new/mind-reader". The slashes in these branch names +is optional, and implies a sort of namespacing if used. + +For instance, make sure you are in the *develop* branch, and create a +new feature branch, based on *develop*, for a new feature you are +creating: + + git checkout develop + git checkout -b new/mind-reader + +Saving changes only updates your local working area. + +## Committing + +Your local changes need to be *committed* to save them in your local +repository. This is where [contribution signing](./signing.md) comes +in. + +You can have as many commits in a branch as you need to "get it right". +For instance, to commit your work from a debugging session: + + git add . + git commit -S -m "Find and fix the broken reference problem" + +Just make sure that your commits in a feature branch are all related. + +If you are working on two features at a time, then you will want to +switch between them to keep the contributions separate. For instance: + + git checkout new/mind-reader + // work away + git add . + git commit -S -m "Added adapter for abc" + git checkout fix/issue-123 + // work away + git add . + git commit -S -m "Fixed problem in DEF\Something" + git checkout develop + +The last checkout makes sure that you end up in your *develop* branch as +a starting point for your next session working with your repository. +This is a good practice, as it is not always obvious which branch you +are working in. + +## Pushing Your Branch + +At some point, you will decide that your feature branch is complete, or +that it could benefit from a review by fellow developers. + +**Note:** +> Remember to sync your local repo with the shared one before pushing! +It is a lot easier to resolve conflicts at this stage. + + +Synchronize your repository: + + git checkout develop + git fetch upstream + git merge upstream/develop + git push origin develop + +Bring your feature branch up to date: + + git checkout new/mind-reader + git rebase upstream/develop + +And finally push your local branch to your GitHub repository: + + git push --force-with-lease origin new/mind-reader + +## Pull Requests + +On GitHub, you propose your changes one feature branch at a time, by +switching to the branch you wish to contribute, and then clicking on +"New pull request". + +Make sure the pull request is for the shared **develop** branch, or it +may be rejected. + +Make sure that the PR title is helpful for the maintainers and other +developers. Add any comments appropriate, for instance asking for +review. + + +**Note:** +> If you do not provide a title or description for your PR, the odds of it being summarily rejected +rise astronomically. + +When your PR is submitted, a continuous integration task will be +triggered, running all the unit tests as well as any other checking we +have configured for it. If the unit tests fail, or if there are merge +conflicts, your PR will not be mergeable until those are fixed. + +Fix such changes locally, commit them properly, and then push your +branch again. That will update the PR automatically, and re-run the CI +tests. You don't need to raise a new PR. + +If your PR does not follow our contribution guidelines, or is +incomplete, the codebase maintainers will comment on it, pointing out +what needs fixing. + +## Cleanup + +If your PR is accepted and merged into the shared repository, you can +delete that branch in your GitHub repository as well as locally. diff --git a/contributing/workflow.rst b/contributing/workflow.rst deleted file mode 100644 index a6cbfa8cae16..000000000000 --- a/contributing/workflow.rst +++ /dev/null @@ -1,206 +0,0 @@ -===================== -Contribution Workflow -===================== - -Much of the workflow for contributing to CodeIgniter (or any project) involves -understanding how `Git `_ is used to -manage a shared repository and contributions to it. -Examples below use the Git bash shell, to be as platform neutral as -possible. Your IDE may make some of these easier. - -Some conventions used below, which you will need to provide appropriate -values for when you try these:: - - ALL_PROJECTS // folder location with all your projects in subfolders, eg /lampp/htdocs - YOUR_PROJECT // folder containing the project you are working on, inside ALL_PROJECTS - ORIGIN_URL // the cloning URL for your repository fork - UPSTREAM_URL // the cloning URL for the CodeIgniter4 repository - -Branching -========= - -CodeIgniter uses the `Git-Flow -`_ branching model, -which requires all pull requests to be sent to the "develop" branch. This is -where the next planned version will be developed. The "master" branch will -always contain the latest stable version and is kept clean so a "hotfix" (e.g: -an emergency security patch) can be applied to master to create a new version, -without worrying about other features holding it up. For this reason, all -commits need to be made to "develop" and any sent to "master" will be closed -automatically. If you have multiple changes to submit, please place each -change into their own branch on your fork. - -One thing at a time: a pull request should only contain one change. That does -not mean only one commit, but one change - however many commits it took. The -reason for this is that if you change X and Y but send a single pull request for both -at the same time, we might really want X but disagree with Y, meaning we -cannot merge the request. Using the Git-Flow branching model you can create -new branches for both of these features and send two requests. - -Forking -======= - -You work with a fork of the CodeIgniter4 repository. This is a copy of our repository, -in your github account. You can make changes to your forked repository, while -you cannot do the same with the shared one - you have to submit pull requests -to it instead. - -`Creating a fork `_ -is done through the Github website. Navigate to `our -repository `_, -click the **Fork** button in the top-right of the page, and choose which account or -organization of yours should contain that fork. - -Cloning -======= - -You *could* work on your repository using Github's web interface, but that is -awkward. Most developers will clone their repository to their local system, -and work with it there. - -On Github, navigate to your forked repository, click **Clone or download**, and -copy the cloning URL shown. We will refer to this as ORIGIN_URL. - -Clone your repository, leaving a local folder for you to work with:: - - cd ALL_PROJECTS - git clone ORIGIN_URL - -Synching -======== - -Within your local repository, Git will have created an alias, **origin**, for the -Github repository it is bound to. You want to create an alias for the shared -repository as well, so that you can "synch" the two, making sure that your repository -includes any other contributions that have been merged by us into the shared repo:: - - git remote add upstream UPSTREAM_URL - -Then synchronizing is done by pulling from us and pushing to you. This is normally -done locally, so that you can resolve any merge conflicts. For instance, to -synchronize **develop** branches:: - - git checkout develop - git pull upstream develop - git push origin develop - -You might get merge conflicts when you pull from upstream. It is your responsibility -to resolve those locally, so that you can continue collaborating with the shared -repository. Basically, the shared repository is updated in the order that contributions -are merged into it, not in the order that they might have been submitted. -If two PRs update the same piece of code, then the first one to be merged -will take precedence, even if it causes problems for other contributions. - -It is a good idea to synchronize repositories when the shared one changes. - -Branching Revisited -=================== - -The top of this page talked about the **master** and **develop** branches. -The *best practice* for your work is to create a *feature branch* locally, -to hold a group of related changes (source, unit testing, documentation, -change log, etc). - -This local branch should be named appropriately, for instance -"fix/problem123" or "new/mind-reader". The slashes in these branch names is -optional, and implies a sort of namespacing if used. - -For instance, make sure you are in the *develop* branch, and create a -new feature branch, based on *develop*, for a new feature you are creating:: - - git checkout develop - git checkout -b new/mind-reader - -Saving changes only updates your local working area. - -Committing -========== - -Your local changes need to be *committed* to save them in your local repository. -This is where `contribution signing <./signing.rst>`_ comes in. - -You can have as many commits in a branch as you need to "get it right". -For instance, to commit your work from a debugging session:: - - git add . - git commit -S -m "Find and fix the broken reference problem" - -Just make sure that your commits in a feature branch are all related. - -If you are working on two features at a time, then you will want to switch -between them to keep the contributions separate. For instance:: - - git checkout new/mind-reader - // work away - git add . - git commit -S -m "Added adapter for abc" - git checkout fix/issue-123 - // work away - git add . - git commit -S -m "Fixed problem in DEF\Something" - git checkout develop - -The last checkout makes sure that you end up in your *develop* branch as a -starting point for your next session working with your repository. -This is a good practice, as it is not always obvious which branch you are working in. - -Pushing Your Branch -=================== - -At some point, you will decide that your feature branch is complete, or that -it could benefit from a review by fellow developers. - -.. note:: - Remember to synch your local repo with the shared one before pushing! - It is a lot easier to resolve conflicts at this stage. - -Synchronize your repository:: - - git checkout develop - git pull upstream develop - git push origin develop - -Bring your feature branch up to date:: - - git checkout new/mind-reader - git merge develop - -And finally push your local branch to your github repository:: - - git push origin new/mind-reader - -Pull Requests -============= - -On Github, you propose your changes one feature branch at a time, by -switching to the branch you wish to contribute, and then clicking -on "New pull request". - -Make sure the pull request is for the shared **develop** branch, or it -may be rejected. - -Make sure that the PR title is helpful for the maintainers and other developers. -Add any comments appropriate, for instance asking for review. - -.. note:: - If you do not provide a title or description for your PR, the odds of it being summarily rejected - rise astronomically. - -When your PR is submitted, a continuous integration task will be triggered, -running all the unit tests as well as any other checking we have configured for it. -If the unit tests fail, or if there are merge conflicts, your PR will not -be mergeable until those are fixed. - -Fix such changes locally, commit them properly, and then push your branch again. -That will update the PR automatically, and re-run the CI tests. You don't need -to raise a new PR. - -If your PR does not follow our contribution guidelines, or is incomplete, -the codebase maintainers will comment on it, pointing out what -needs fixing. - -Cleanup -======= - -If your PR is accepted and merged into the shared repository, you can delete -that branch in your github repository as well as locally. \ No newline at end of file diff --git a/depfile.yaml b/depfile.yaml new file mode 100644 index 000000000000..2c87969f4fc4 --- /dev/null +++ b/depfile.yaml @@ -0,0 +1,230 @@ +# Defines the layers for each framework +# component and their allowed interactions. +# The following components are exempt +# due to their global nature: +# - CLI & Commands +# - Config +# - Debug +# - Exception +# - Service +# - Validation\FormatRules +paths: + - ./app + - ./system +exclude_files: + - '#.*test.*#i' +layers: + - name: API + collectors: + - type: className + regex: ^Codeigniter\\API\\.* + - name: Cache + collectors: + - type: className + regex: ^Codeigniter\\Cache\\.* + - name: Controller + collectors: + - type: className + regex: ^CodeIgniter\\Controller$ + - name: Cookie + collectors: + - type: className + regex: ^Codeigniter\\Cookie\\.* + - name: Database + collectors: + - type: className + regex: ^Codeigniter\\Database\\.* + - name: Email + collectors: + - type: className + regex: ^Codeigniter\\Email\\.* + - name: Encryption + collectors: + - type: className + regex: ^Codeigniter\\Encryption\\.* + - name: Entity + collectors: + - type: className + regex: ^Codeigniter\\Entity\\.* + - name: Events + collectors: + - type: className + regex: ^Codeigniter\\Events\\.* + - name: Files + collectors: + - type: className + regex: ^Codeigniter\\Files\\.* + - name: Filters + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\Filters\\Filter.* + - name: Format + collectors: + - type: className + regex: ^Codeigniter\\Format\\.* + - name: Honeypot + collectors: + - type: className + regex: ^Codeigniter\\.*Honeypot.* # includes the Filter + - name: HTTP + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\HTTP\\.* + must_not: + - type: className + regex: (Exception|URI) + - name: I18n + collectors: + - type: className + regex: ^Codeigniter\\I18n\\.* + - name: Images + collectors: + - type: className + regex: ^Codeigniter\\Images\\.* + - name: Language + collectors: + - type: className + regex: ^Codeigniter\\Language\\.* + - name: Log + collectors: + - type: className + regex: ^Codeigniter\\Log\\.* + - name: Model + collectors: + - type: className + regex: ^Codeigniter\\.*Model$ + - name: Modules + collectors: + - type: className + regex: ^Codeigniter\\Modules\\.* + - name: Pager + collectors: + - type: className + regex: ^Codeigniter\\Pager\\.* + - name: Publisher + collectors: + - type: className + regex: ^Codeigniter\\Publisher\\.* + - name: RESTful + collectors: + - type: className + regex: ^Codeigniter\\RESTful\\.* + - name: Router + collectors: + - type: className + regex: ^Codeigniter\\Router\\.* + - name: Security + collectors: + - type: className + regex: ^Codeigniter\\Security\\.* + - name: Session + collectors: + - type: className + regex: ^Codeigniter\\Session\\.* + - name: Throttle + collectors: + - type: className + regex: ^Codeigniter\\Throttle\\.* + - name: Typography + collectors: + - type: className + regex: ^Codeigniter\\Typography\\.* + - name: URI + collectors: + - type: className + regex: ^CodeIgniter\\HTTP\\URI$ + - name: Validation + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\Validation\\.* + must_not: + - type: className + regex: ^Codeigniter\\Validation\\FormatRules$ + - name: View + collectors: + - type: className + regex: ^Codeigniter\\View\\.* +ruleset: + API: + - Format + - HTTP + Controller: + - HTTP + - Validation + Database: + - Entity + - Events + Email: + - Events + Entity: + - I18n + Filters: + - HTTP + Honeypot: + - Filters + - HTTP + HTTP: + - Cookie + - Files + - URI + Images: + - Files + Model: + - Database + - I18n + - Pager + - Validation + Pager: + - URI + - View + Publisher: + - Files + - URI + RESTful: + - +API + - +Controller + Router: + - HTTP + Security: + - Cookie + - Session + - HTTP + Session: + - Cookie + - Database + Throttle: + - Cache + Validation: + - HTTP + View: + - Cache +skip_violations: + # Individual class exemptions + CodeIgniter\Entity\Cast\URICast: + - CodeIgniter\HTTP\URI + CodeIgniter\Log\Handlers\ChromeLoggerHandler: + - CodeIgniter\HTTP\ResponseInterface + CodeIgniter\View\Table: + - CodeIgniter\Database\BaseResult + CodeIgniter\View\Plugins: + - CodeIgniter\HTTP\URI + + # BC changes that should be fixed + CodeIgniter\HTTP\ResponseTrait: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\ResponseInterface: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\Response: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\RedirectResponse: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\DownloadResponse: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\Validation\Validation: + - CodeIgniter\View\RendererInterface diff --git a/env b/env index 38eabf203988..6e30e34b7ddb 100644 --- a/env +++ b/env @@ -110,6 +110,7 @@ # SECURITY #-------------------------------------------------------------------- +# security.csrfProtection = 'cookie' # security.tokenName = 'csrf_token_name' # security.headerName = 'X-CSRF-TOKEN' # security.cookieName = 'csrf_cookie_name' @@ -123,3 +124,9 @@ #-------------------------------------------------------------------- # logger.threshold = 4 + +#-------------------------------------------------------------------- +# CURLRequest +#-------------------------------------------------------------------- + +# curlrequest.shareOptions = true diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3847a2f02a69..73e3a7a3a545 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -29,11 +29,10 @@ parameters: - system/ThirdParty/* - system/Validation/Views/single.php ignoreErrors: - - '#Access to an undefined property CodeIgniter\\Database\\Forge::\$dropConstraintStr#' - '#Access to an undefined property CodeIgniter\\Database\\BaseConnection::\$mysqli|\$schema#' - '#Access to an undefined property CodeIgniter\\Database\\ConnectionInterface::(\$DBDriver|\$connID|\$likeEscapeStr|\$likeEscapeChar|\$escapeChar|\$protectIdentifiers|\$schema)#' - '#Call to an undefined method CodeIgniter\\Database\\BaseConnection::_(disable|enable)ForeignKeyChecks\(\)#' - - '#Call to an undefined method CodeIgniter\\Router\\RouteCollectionInterface::(getDefaultNamespace|isFiltered|getFilterForRoute|getRoutesOptions)\(\)#' + - '#Call to an undefined method CodeIgniter\\Router\\RouteCollectionInterface::(getDefaultNamespace|isFiltered|getFilterForRoute|getFiltersForRoute|getRoutesOptions)\(\)#' - '#Cannot access property [\$a-z_]+ on ((bool\|)?object\|resource)#' - '#Cannot call method [a-zA-Z_]+\(\) on ((bool\|)?object\|resource)#' - '#Method CodeIgniter\\Router\\RouteCollectionInterface::getRoutes\(\) invoked with 1 parameter, 0 required#' diff --git a/rector.php b/rector.php index 054ed3ea09ad..4a5fde83a45c 100644 --- a/rector.php +++ b/rector.php @@ -9,6 +9,7 @@ * the LICENSE file that was distributed with this source code. */ +use Rector\CodeQuality\Rector\BooleanAnd\SimplifyEmptyArrayCheckRector; use Rector\CodeQuality\Rector\Expression\InlineIfToExplicitIfRector; use Rector\CodeQuality\Rector\For_\ForToForeachRector; use Rector\CodeQuality\Rector\Foreach_\UnusedForeachValueToArrayKeysRector; @@ -20,7 +21,6 @@ use Rector\CodeQuality\Rector\If_\ShortenElseIfRector; use Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector; use Rector\CodeQuality\Rector\If_\SimplifyIfReturnBoolRector; -use Rector\CodeQuality\Rector\Name\FixClassCaseSensitivityNameRector; use Rector\CodeQuality\Rector\Return_\SimplifyUselessVariableRector; use Rector\CodeQuality\Rector\Ternary\UnnecessaryTernaryExpressionRector; use Rector\CodingStyle\Rector\ClassMethod\FuncGetArgsToVariadicParamRector; @@ -28,7 +28,6 @@ use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\Core\Configuration\Option; use Rector\Core\ValueObject\PhpVersion; -use Rector\DeadCode\Rector\Cast\RecastingRemovalRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector; use Rector\DeadCode\Rector\If_\UnwrapFutureCompatibleIfPhpVersionRector; @@ -37,11 +36,13 @@ use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector; use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; -use Rector\Php70\Rector\Ternary\TernaryToNullCoalescingRector; -use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; -use Rector\Php71\Rector\List_\ListToArrayDestructRector; +use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; +use Rector\Php56\Rector\FunctionLike\AddDefaultValueForUndefinedVariableRector; +use Rector\Php70\Rector\FuncCall\RandomFunctionRector; +use Rector\Php71\Rector\FuncCall\CountOnNullRector; use Rector\Php73\Rector\FuncCall\JsonThrowOnErrorRector; use Rector\Php73\Rector\FuncCall\StringifyStrNeedlesRector; +use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Utils\Rector\PassStrictParameterToFunctionParameterRector; @@ -51,7 +52,7 @@ return static function (ContainerConfigurator $containerConfigurator): void { $containerConfigurator->import(SetList::DEAD_CODE); - $containerConfigurator->import(SetList::PHP_73); + $containerConfigurator->import(LevelSetList::UP_TO_PHP_73); $parameters = $containerConfigurator->parameters(); @@ -91,15 +92,23 @@ __DIR__ . '/system/CodeIgniter.php', ], - // casted to Entity via EntityTest->getCastEntity() - RecastingRemovalRector::class => [ - __DIR__ . '/tests/system/Entity/EntityTest.php', - ], - // session handlers have the gc() method with underscored parameter `$max_lifetime` UnderscoreToCamelCaseVariableNameRector::class => [ __DIR__ . '/system/Session/Handlers', ], + + // may cause load view files directly when detecting class that + // make warning + StringClassNameToClassConstantRector::class, + + // sometime too detail + CountOnNullRector::class, + + // may not be unitialized on purpose + AddDefaultValueForUndefinedVariableRector::class, + + // use mt_rand instead of random_int on purpose on non-cryptographically random + RandomFunctionRector::class, ]); // auto import fully qualified class names @@ -107,8 +116,6 @@ $parameters->set(Option::PHP_VERSION_FEATURES, PhpVersion::PHP_73); $services = $containerConfigurator->services(); - $services->load('Symplify\\PackageBuilder\\', __DIR__ . '/vendor/symplify/package-builder/src'); - $services->set(UnderscoreToCamelCaseVariableNameRector::class); $services->set(SimplifyUselessVariableRector::class); $services->set(RemoveAlwaysElseRector::class); @@ -128,13 +135,10 @@ $services->set(ChangeArrayPushToArrayAssignRector::class); $services->set(UnnecessaryTernaryExpressionRector::class); $services->set(RemoveErrorSuppressInTryCatchStmtsRector::class); - $services->set(TernaryToNullCoalescingRector::class); - $services->set(ListToArrayDestructRector::class); $services->set(RemoveVarTagFromClassConstantRector::class); $services->set(AddPregQuoteDelimiterRector::class); $services->set(SimplifyRegexPatternRector::class); - $services->set(RemoveExtraParametersRector::class); $services->set(FuncGetArgsToVariadicParamRector::class); $services->set(MakeInheritedMethodVisibilitySameAsParentRector::class); - $services->set(FixClassCaseSensitivityNameRector::class); + $services->set(SimplifyEmptyArrayCheckRector::class); }; diff --git a/system/BaseModel.php b/system/BaseModel.php index e57ac4fec6bd..6b9ef3687817 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -366,7 +366,7 @@ abstract protected function doInsert(array $data); * This methods works only with dbCalls * * @param array|null $set An associative array of insert values - * @param bool|null $escape Whether to escape values and identifiers + * @param bool|null $escape Whether to escape values * @param int $batchSize The size of the batch to run * @param bool $testing True means only number of records is returned, false will execute the query * @@ -763,7 +763,7 @@ public function insert($data = null, bool $returnID = true) * Compiles batch insert runs the queries, validating each row prior. * * @param array|null $set an associative array of insert values - * @param bool|null $escape Whether to escape values and identifiers + * @param bool|null $escape Whether to escape values * @param int $batchSize The size of the batch to run * @param bool $testing True means only number of records is returned, false will execute the query * @@ -1031,6 +1031,10 @@ public function replace(?array $data = null, bool $returnSQL = false) return false; } + if ($this->useTimestamps && $this->updatedField && ! array_key_exists($this->updatedField, (array) $data)) { + $data[$this->updatedField] = $this->setDate(); + } + return $this->doReplace($data, $returnSQL); } @@ -1079,7 +1083,7 @@ public function paginate(?int $perPage = null, string $group = 'default', ?int $ // Store it in the Pager library, so it can be paginated in the views. $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment); $perPage = $this->pager->getPerPage($group); - $offset = ($page - 1) * $perPage; + $offset = ($pager->getCurrentPage($group) - 1) * $perPage; return $this->findAll($perPage, $offset); } diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index b463aa6a3016..ea1e1ad85611 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -228,38 +228,77 @@ public static function prompt(string $field, $options = null, $validation = null } if (is_string($options)) { - $extraOutput = ' [' . static::color($options, 'white') . ']'; + $extraOutput = ' [' . static::color($options, 'green') . ']'; $default = $options; } if (is_array($options) && $options) { $opts = $options; - $extraOutputDefault = static::color($opts[0], 'white'); + $extraOutputDefault = static::color($opts[0], 'green'); unset($opts[0]); if (empty($opts)) { $extraOutput = $extraOutputDefault; } else { - $extraOutput = ' [' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; - $validation[] = 'in_list[' . implode(',', $options) . ']'; + $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; + $validation[] = 'in_list[' . implode(', ', $options) . ']'; } $default = $options[0]; } - static::fwrite(STDOUT, $field . $extraOutput . ': '); + static::fwrite(STDOUT, $field . (trim($field) ? ' ' : '') . $extraOutput . ': '); // Read the input from keyboard. $input = trim(static::input()) ?: $default; if ($validation) { - while (! static::validate($field, $input, $validation)) { + while (! static::validate(trim($field), $input, $validation)) { $input = static::prompt($field, $options, $validation); } } - return empty($input) ? '' : $input; + return $input; + } + + /** + * prompt(), but based on the option's key + * + * @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param array|string|null $validation Validation rules + * + * @return string The selected key of $options + * + * @codeCoverageIgnore + */ + public static function promptByKey($text, array $options, $validation = null): string + { + if (is_string($text)) { + $text = [$text]; + } elseif (! is_array($text)) { + throw new InvalidArgumentException('$text can only be of type string|array'); + } + + if (! $options) { + throw new InvalidArgumentException('No options to select from were provided'); + } + + if ($line = array_shift($text)) { + CLI::write($line); + } + + // +2 for the square brackets around the key + $keyMaxLength = max(array_map('mb_strwidth', array_keys($options))) + 2; + + foreach ($options as $key => $description) { + $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' '); + CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4)); + } + + return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation); } /** diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index b0668a49f4d2..51706b477cd4 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -215,14 +215,14 @@ protected function qualifyClassName(): string // Trims input, normalize separators, and ensure that all paths are in Pascalcase. $class = ltrim(implode('\\', array_map('pascalize', explode('\\', str_replace('/', '\\', trim($class))))), '\\/'); - // Gets the namespace from input. - $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\'); + // Gets the namespace from input. Don't forget the ending backslash! + $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\') . '\\'; if (strncmp($class, $namespace, strlen($namespace)) === 0) { return $class; // @codeCoverageIgnore } - return $namespace . '\\' . $this->directory . '\\' . str_replace('/', '\\', $class); + return $namespace . $this->directory . '\\' . str_replace('/', '\\', $class); } /** diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index 720fae0e4c6c..875dcd3f7bcd 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Test\Mock\MockCache; use Config\Cache; /** @@ -20,6 +21,20 @@ */ class CacheFactory { + /** + * The class to use when mocking + * + * @var string + */ + public static $mockClass = MockCache::class; + + /** + * The service to inject the mock as + * + * @var string + */ + public static $mockServiceName = 'cache'; + /** * Attempts to create the desired cache handler, based upon the * @@ -42,14 +57,12 @@ public static function getHandler(Cache $config, ?string $handler = null, ?strin throw CacheException::forHandlerNotFound(); } - // Get an instance of our handler. $adapter = new $config->validHandlers[$handler]($config); if (! $adapter->isSupported()) { $adapter = new $config->validHandlers[$backup]($config); if (! $adapter->isSupported()) { - // Log stuff here, don't throw exception. No need to raise a fuss. // Fall back to the dummy adapter. $adapter = new $config->validHandlers['dummy'](); } @@ -60,7 +73,6 @@ public static function getHandler(Cache $config, ?string $handler = null, ?strin try { $adapter->initialize(); } catch (CriticalError $e) { - // log the fact that an exception occurred as well what handler we are resorting to log_message('critical', $e->getMessage() . ' Resorting to using ' . $backup . ' handler.'); // get the next best cache handler (or dummy if the $backup also fails) diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index cae03b901664..57120208ceeb 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -22,8 +22,10 @@ abstract class BaseHandler implements CacheInterface { /** - * Reserved characters that cannot be used in a key or tag. + * Reserved characters that cannot be used in a key or tag. May be overridden by the config. * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43 + * + * @deprecated in favor of the Cache config */ public const RESERVED_CHARACTERS = '{}()/\@:'; @@ -58,8 +60,10 @@ public static function validateKey($key, $prefix = ''): string if ($key === '') { throw new InvalidArgumentException('Cache key cannot be empty.'); } - if (strpbrk($key, self::RESERVED_CHARACTERS) !== false) { - throw new InvalidArgumentException('Cache key contains reserved characters ' . self::RESERVED_CHARACTERS); + + $reserved = config('Cache')->reservedCharacters ?? self::RESERVED_CHARACTERS; + if ($reserved && strpbrk($key, $reserved) !== false) { + throw new InvalidArgumentException('Cache key contains reserved characters ' . $reserved); } // If the key with prefix exceeds the length then return the hashed version diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index db2e5d6574ee..a7df6971b4bc 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -206,11 +206,11 @@ public function getMetaData(string $key) $key = static::validateKey($key, $this->prefix); if (false === $data = $this->getItem($key)) { - return false; // This will return null in a future release + return false; // @TODO This will return null in a future release } return [ - 'expire' => $data['time'] + $data['ttl'], + 'expire' => $data['ttl'] > 0 ? $data['time'] + $data['ttl'] : null, 'mtime' => filemtime($this->path . $key), 'data' => $data['data'], ]; diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 2667abca92a6..93c8cc59e0b5 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -251,7 +251,7 @@ public function getMetaData(string $key) // if not an array, don't try to count for PHP7.2 if (! is_array($stored) || count($stored) !== 3) { - return false; // This will return null in a future release + return false; // @TODO This will return null in a future release } [$data, $time, $limit] = $stored; diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 374b3895ed05..5d1f37cdfc24 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -127,7 +127,9 @@ public function save(string $key, $value, int $ttl = 60) return false; } - $this->redis->expireat($key, time() + $ttl); + if ($ttl) { + $this->redis->expireat($key, time() + $ttl); + } return true; } diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php index 368408af7217..b13ee30c3ff6 100644 --- a/system/Cache/Handlers/WincacheHandler.php +++ b/system/Cache/Handlers/WincacheHandler.php @@ -131,7 +131,7 @@ public function getMetaData(string $key) ]; } - return false; // This will return null in a future release + return false; // @TODO This will return null in a future release } /** diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 4350876bd3a2..a8ebeef1b5cf 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; @@ -44,7 +45,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.1.4'; + public const CI_VERSION = '4.1.5'; private const MIN_PHP_VERSION = '7.3'; @@ -357,21 +358,31 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache { $routeFilter = $this->tryToRouteIt($routes); - // Run "before" filters + $uri = $this->determinePath(); + + // Start up the filters $filters = Services::filters(); // If any filters were specified within the routes file, // we need to ensure it's active for the current request if ($routeFilter !== null) { - $filters->enableFilter($routeFilter, 'before'); - $filters->enableFilter($routeFilter, 'after'); + $multipleFiltersEnabled = config('Feature')->multipleFilters ?? false; + if ($multipleFiltersEnabled) { + $filters->enableFilters($routeFilter, 'before'); + $filters->enableFilters($routeFilter, 'after'); + } else { + // for backward compatibility + $filters->enableFilter($routeFilter, 'before'); + $filters->enableFilter($routeFilter, 'after'); + } } - $uri = $this->determinePath(); - // Never run filters when running through Spark cli if (! defined('SPARKED')) { + // Run "before" filters + $this->benchmark->start('before_filters'); $possibleResponse = $filters->run($uri, 'before'); + $this->benchmark->stop('before_filters'); // If a ResponseInterface instance is returned then send it back to the client and stop if ($possibleResponse instanceof ResponseInterface) { @@ -410,8 +421,11 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // Never run filters when running through Spark cli if (! defined('SPARKED')) { $filters->setResponse($this->response); + // Run "after" filters + $this->benchmark->start('after_filters'); $response = $filters->run($uri, 'after'); + $this->benchmark->stop('after_filters'); } else { $response = $this->response; @@ -490,7 +504,9 @@ protected function bootstrapEnvironment() */ protected function startBenchmark() { - $this->startTime = microtime(true); + if ($this->startTime === null) { + $this->startTime = microtime(true); + } $this->benchmark = Services::timer(); $this->benchmark->start('total_execution', $this->startTime); @@ -681,7 +697,7 @@ public function displayPerformanceMetrics(string $output): string * * @throws RedirectException * - * @return string|null + * @return string|string[]|null */ protected function tryToRouteIt(?RouteCollectionInterface $routes = null) { @@ -710,7 +726,13 @@ protected function tryToRouteIt(?RouteCollectionInterface $routes = null) $this->benchmark->stop('routing'); - return $this->router->getFilter(); + // for backward compatibility + $multipleFiltersEnabled = config('Feature')->multipleFilters ?? false; + if (! $multipleFiltersEnabled) { + return $this->router->getFilter(); + } + + return $this->router->getFilters(); } /** @@ -913,14 +935,19 @@ protected function gatherOutput(?Cache $cacheConfig = null, $returned = null) public function storePreviousURL($uri) { // Ignore CLI requests - if (is_cli()) { - return; + if (is_cli() && ENVIRONMENT !== 'testing') { + return; // @codeCoverageIgnore } // Ignore AJAX requests if (method_exists($this->request, 'isAJAX') && $this->request->isAJAX()) { return; } + // Ignore unroutable responses + if ($this->response instanceof DownloadResponse || $this->response instanceof RedirectResponse) { + return; + } + // This is mainly needed during testing... if (is_string($uri)) { $uri = new URI($uri); diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index c43713efe893..810f2dc92d39 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -125,7 +125,7 @@ protected function setNewEncryptionKey(string $key, array $params): bool { $currentKey = env('encryption.key', ''); - if (strlen($currentKey) !== 0 && ! $this->confirmOverwrite($params)) { + if ($currentKey !== '' && ! $this->confirmOverwrite($params)) { // Not yet testable since it requires keyboard input // @codeCoverageIgnoreStart return false; diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index 1cd931e9e460..24ab3ea72330 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -101,10 +101,11 @@ protected function prepare(string $class): string $table = $this->getOption('table'); $DBGroup = $this->getOption('dbgroup'); - $data['session'] = true; - $data['table'] = is_string($table) ? $table : 'ci_sessions'; - $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; - $data['matchIP'] = config('App')->sessionMatchIP; + $data['session'] = true; + $data['table'] = is_string($table) ? $table : 'ci_sessions'; + $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; + $data['DBDriver'] = config('Database')->{$data['DBGroup']}['DBDriver']; + $data['matchIP'] = config('App')->sessionMatchIP; } return $this->parseTemplate($class, [], [], $data); diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index 1ad8a75e1f8c..b4b7ffcda2ca 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -92,14 +92,17 @@ public function run(array $params) protected function prepare(string $class): string { $table = $this->getOption('table'); - $DBGroup = $this->getOption('dbgroup'); + $dbGroup = $this->getOption('dbgroup'); $return = $this->getOption('return'); - $baseClass = strtolower(str_replace(trim(implode('\\', array_slice(explode('\\', $class), 0, -1)), '\\') . '\\', '', $class)); - $baseClass = strpos($baseClass, 'model') ? str_replace('model', '', $baseClass) : $baseClass; + $baseClass = class_basename($class); - $table = is_string($table) ? $table : plural($baseClass); - $DBGroup = is_string($DBGroup) ? $DBGroup : 'default'; + if (preg_match('/^(\S+)Model$/i', $baseClass, $match) === 1) { + $baseClass = $match[1]; + } + + $table = is_string($table) ? $table : plural(strtolower($baseClass)); + $dbGroup = is_string($dbGroup) ? $dbGroup : 'default'; $return = is_string($return) ? $return : 'array'; if (! in_array($return, ['array', 'object', 'entity'], true)) { @@ -112,17 +115,20 @@ protected function prepare(string $class): string if ($return === 'entity') { $return = str_replace('Models', 'Entities', $class); - if ($pos = strpos($return, 'Model')) { - $return = substr($return, 0, $pos); + if (preg_match('/^(\S+)Model$/i', $return, $match) === 1) { + $return = $match[1]; if ($this->getOption('suffix')) { $return .= 'Entity'; } } + $return = '\\' . trim($return, '\\') . '::class'; $this->call('make:entity', array_merge([$baseClass], $this->params)); + } else { + $return = "'{$return}'"; } - return $this->parseTemplate($class, ['{table}', '{DBGroup}', '{return}'], [$table, $DBGroup, $return]); + return $this->parseTemplate($class, ['{table}', '{dbGroup}', '{return}'], [$table, $dbGroup, $return]); } } diff --git a/system/Commands/Generators/Views/entity.tpl.php b/system/Commands/Generators/Views/entity.tpl.php index 6623e2f14bbb..c74c776f4ad3 100644 --- a/system/Commands/Generators/Views/entity.tpl.php +++ b/system/Commands/Generators/Views/entity.tpl.php @@ -7,10 +7,6 @@ class {class} extends Entity { protected $datamap = []; - protected $dates = [ - 'created_at', - 'updated_at', - 'deleted_at', - ]; + protected $dates = ['created_at', 'updated_at', 'deleted_at']; protected $casts = []; } diff --git a/system/Commands/Generators/Views/migration.tpl.php b/system/Commands/Generators/Views/migration.tpl.php index 436ee85e04cc..321895e670c2 100644 --- a/system/Commands/Generators/Views/migration.tpl.php +++ b/system/Commands/Generators/Views/migration.tpl.php @@ -12,17 +12,23 @@ class {class} extends Migration public function up() { $this->forge->addField([ - 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false], - 'timestamp' => ['type' => 'INT', 'unsigned' => true, 'null' => false, 'default' => 0], - 'data' => ['type' => 'TEXT', 'null' => false, 'default' => ''], + 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL', + 'data' => ['type' => 'BLOB', 'null' => false], + + 'ip_address inet NOT NULL', + 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL', + "data bytea DEFAULT '' NOT NULL", + ]); - - $this->forge->addKey(['id', 'ip_address'], true); - - $this->forge->addKey('id', true); - - $this->forge->addKey('timestamp'); + + $this->forge->addKey(['id', 'ip_address'], true); + + $this->forge->addKey('id', true); + + $this->forge->addKey('timestamp'); $this->forge->createTable('', true); } diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php index 088ecb7e1161..5fc5ed9ba428 100644 --- a/system/Commands/Generators/Views/model.tpl.php +++ b/system/Commands/Generators/Views/model.tpl.php @@ -6,22 +6,22 @@ class {class} extends Model { - protected $DBGroup = '{DBGroup}'; - protected $table = '{table}'; - protected $primaryKey = 'id'; - protected $useAutoIncrement = true; - protected $insertID = 0; - protected $returnType = '{return}'; - protected $useSoftDeletes = false; - protected $protectFields = true; - protected $allowedFields = []; + protected $DBGroup = '{dbGroup}'; + protected $table = '{table}'; + protected $primaryKey = 'id'; + protected $useAutoIncrement = true; + protected $insertID = 0; + protected $returnType = {return}; + protected $useSoftDeletes = false; + protected $protectFields = true; + protected $allowedFields = []; // Dates - protected $useTimestamps = false; - protected $dateFormat = 'datetime'; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; - protected $deletedField = 'deleted_at'; + protected $useTimestamps = false; + protected $dateFormat = 'datetime'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; + protected $deletedField = 'deleted_at'; // Validation protected $validationRules = []; @@ -30,13 +30,13 @@ class {class} extends Model protected $cleanValidationRules = true; // Callbacks - protected $allowCallbacks = true; - protected $beforeInsert = []; - protected $afterInsert = []; - protected $beforeUpdate = []; - protected $afterUpdate = []; - protected $beforeFind = []; - protected $afterFind = []; - protected $beforeDelete = []; - protected $afterDelete = []; + protected $allowCallbacks = true; + protected $beforeInsert = []; + protected $afterInsert = []; + protected $beforeUpdate = []; + protected $afterUpdate = []; + protected $beforeFind = []; + protected $afterFind = []; + protected $beforeDelete = []; + protected $afterDelete = []; } diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php new file mode 100644 index 000000000000..cfed0472c439 --- /dev/null +++ b/system/Commands/Utilities/Publish.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Publisher\Publisher; + +/** + * Discovers all Publisher classes from the "Publishers/" directory + * across namespaces. Executes `publish()` from each instance, parsing + * each result. + */ +class Publish extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'publish'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Discovers and executes all predefined Publisher classes.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'publish []'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'directory' => '[Optional] The directory to scan within each namespace. Default: "Publishers".', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params) + { + $directory = array_shift($params) ?? 'Publishers'; + + if ([] === $publishers = Publisher::discover($directory)) { + CLI::write(lang('Publisher.publishMissing', [$directory])); + + return; + } + + foreach ($publishers as $publisher) { + if ($publisher->publish()) { + CLI::write(lang('Publisher.publishSuccess', [ + get_class($publisher), + count($publisher->getPublished()), + $publisher->getDestination(), + ]), 'green'); + } else { + CLI::error(lang('Publisher.publishFailure', [ + get_class($publisher), + $publisher->getDestination(), + ]), 'light_gray', 'red'); + + foreach ($publisher->getErrors() as $file => $exception) { + CLI::write($file); + CLI::error($exception->getMessage()); + CLI::newLine(); + } + } + } + } +} diff --git a/system/Common.php b/system/Common.php index 19be6398a8e1..7c150a01c1d4 100644 --- a/system/Common.php +++ b/system/Common.php @@ -23,6 +23,7 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; +use CodeIgniter\Model; use CodeIgniter\Session\Session; use CodeIgniter\Test\TestLogger; use Config\App; @@ -559,7 +560,7 @@ function helper($filenames) { static $loaded = []; - $loader = Services::locator(true); + $loader = Services::locator(); if (! is_array($filenames)) { $filenames = [$filenames]; @@ -595,18 +596,14 @@ function helper($filenames) $includes[] = $path; $loaded[] = $filename; - } - - // No namespaces, so search in all available locations - else { + } else { + // No namespaces, so search in all available locations $paths = $loader->search('Helpers/' . $filename); foreach ($paths as $path) { - if (strpos($path, APPPATH) === 0) { - // @codeCoverageIgnoreStart + if (strpos($path, APPPATH . 'Helpers' . DIRECTORY_SEPARATOR) === 0) { $appHelper = $path; - // @codeCoverageIgnoreEnd - } elseif (strpos($path, SYSTEMPATH) === 0) { + } elseif (strpos($path, SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR) === 0) { $systemHelper = $path; } else { $localIncludes[] = $path; @@ -616,10 +613,8 @@ function helper($filenames) // App-level helpers should override all others if (! empty($appHelper)) { - // @codeCoverageIgnoreStart $includes[] = $appHelper; $loaded[] = $filename; - // @codeCoverageIgnoreEnd } // All namespaced files get added in next @@ -644,22 +639,14 @@ function helper($filenames) /** * Check if PHP was invoked from the command line. * - * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in CLI + * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in php-cli */ function is_cli(): bool { - if (PHP_SAPI === 'cli') { - return true; - } - if (defined('STDIN')) { return true; } - if (stristr(PHP_SAPI, 'cgi') && getenv('TERM')) { - return true; - } - if (! isset($_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT']) && isset($_SERVER['argv']) && count($_SERVER['argv']) > 0) { return true; } @@ -725,8 +712,23 @@ function is_really_writable(string $file): bool */ function lang(string $line, array $args = [], ?string $locale = null) { - return Services::language($locale) - ->getLine($line, $args); + $language = Services::language(); + + // Get active locale + $activeLocale = $language->getLocale(); + + if ($locale && $locale !== $activeLocale) { + $language->setLocale($locale); + } + + $line = $language->getLine($line, $args); + + if ($locale && $locale !== $activeLocale) { + // Reset to active locale + $language->setLocale($activeLocale); + } + + return $line; } } @@ -769,7 +771,12 @@ function log_message(string $level, string $message, array $context = []) /** * More simple way of getting model instances from Factories * - * @return mixed + * @template T of Model + * + * @param class-string $name + * + * @return T + * @phpstan-return Model */ function model(string $name, bool $getShared = true, ?ConnectionInterface &$conn = null) { @@ -819,9 +826,7 @@ function old(string $key, $default = null, $escape = 'html') /** * Convenience method that works with the current global $request and * $router instances to redirect using named/reverse-routed routes - * to determine the URL to go to. If nothing is found, will treat - * as a traditional redirect and pass the string in, letting - * $response->redirect() determine the correct method and code. + * to determine the URL to go to. * * If more control is needed, you must use $response->redirect explicitly. * diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index c543492f3348..9b71a4963bab 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -115,24 +115,39 @@ protected function initEnvValue(&$property, string $name, string $prefix, string */ protected function getEnvValue(string $property, string $prefix, string $shortPrefix) { - $shortPrefix = ltrim($shortPrefix, '\\'); + $shortPrefix = ltrim($shortPrefix, '\\'); + $underscoreProperty = str_replace('.', '_', $property); switch (true) { case array_key_exists("{$shortPrefix}.{$property}", $_ENV): return $_ENV["{$shortPrefix}.{$property}"]; + case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_ENV): + return $_ENV["{$shortPrefix}_{$underscoreProperty}"]; + case array_key_exists("{$shortPrefix}.{$property}", $_SERVER): return $_SERVER["{$shortPrefix}.{$property}"]; + case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER): + return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; + case array_key_exists("{$prefix}.{$property}", $_ENV): return $_ENV["{$prefix}.{$property}"]; + case array_key_exists("{$prefix}_{$underscoreProperty}", $_ENV): + return $_ENV["{$prefix}_{$underscoreProperty}"]; + case array_key_exists("{$prefix}.{$property}", $_SERVER): return $_SERVER["{$prefix}.{$property}"]; + case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER): + return $_SERVER["{$prefix}_{$underscoreProperty}"]; + default: $value = getenv("{$shortPrefix}.{$property}"); + $value = $value === false ? getenv("{$shortPrefix}_{$underscoreProperty}") : $value; $value = $value === false ? getenv("{$prefix}.{$property}") : $value; + $value = $value === false ? getenv("{$prefix}_{$underscoreProperty}") : $value; return $value === false ? null : $value; } diff --git a/system/Config/Factories.php b/system/Config/Factories.php index 8aa779220aed..8bcef3e09610 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -17,7 +17,7 @@ /** * Factories for creating instances. * - * Factories allows dynamic loading of components by their path + * Factories allow dynamic loading of components by their path * and name. The "shared instance" implementation provides a * large performance boost and helps keep code clean of lengthy * instantiation checks. diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php new file mode 100644 index 000000000000..608e87afef8e --- /dev/null +++ b/system/Config/Publisher.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Publisher Configuration + * + * Defines basic security restrictions for the Publisher class + * to prevent abuse by injecting malicious files into a project. + */ +class Publisher extends BaseConfig +{ + /** + * A list of allowed destinations with a (pseudo-)regex + * of allowed files for each destination. + * Attempts to publish to directories not in this list will + * result in a PublisherException. Files that do no fit the + * pattern will cause copy/merge to fail. + * + * @var array + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; + + /** + * Disables Registrars to prevent modules from altering the restrictions. + */ + final protected function registerProperties() + { + } +} diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 7c3253c52685..74b9dffad0e4 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -244,6 +244,20 @@ class BaseBuilder 'RIGHT OUTER', ]; + /** + * Strings that determine if a string represents a literal value or a field name + * + * @var string[] + */ + protected $isLiteralStr = []; + + /** + * RegExp used to get operators + * + * @var string[] + */ + protected $pregOperators = []; + /** * Constructor * @@ -1003,7 +1017,7 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri $bind = $this->setBind($k, "%{$v}%", $escape); } - $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch); + $likeStatement = $this->_like_statement($prefix, $this->db->protectIdentifiers($k, false, $escape), $not, $bind, $insensitiveSearch); // some platforms require an escape sequence definition for LIKE wildcards if ($escape === true && $this->db->likeEscapeStr !== '') { @@ -1260,6 +1274,7 @@ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = if ($direction === 'RANDOM') { $direction = ''; $orderBy = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0]; + $escape = false; } elseif ($direction !== '') { $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : ''; } @@ -1339,12 +1354,12 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string * Allows key/value pairs to be set for insert(), update() or replace(). * * @param array|object|string $key Field name, or an array of field/value pairs - * @param string|null $value Field value, if $key is a single field - * @param bool|null $escape Whether to escape values and identifiers + * @param mixed $value Field value, if $key is a single field + * @param bool|null $escape Whether to escape values * * @return $this */ - public function set($key, ?string $value = '', ?bool $escape = null) + public function set($key, $value = '', ?bool $escape = null) { $key = $this->objectToArray($key); @@ -1358,9 +1373,9 @@ public function set($key, ?string $value = '', ?bool $escape = null) if ($escape) { $bind = $this->setBind($k, $v, $escape); - $this->QBSet[$this->db->protectIdentifiers($k, false, $escape)] = ":{$bind}:"; + $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:"; } else { - $this->QBSet[$this->db->protectIdentifiers($k, false, $escape)] = $v; + $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v; } } @@ -1575,7 +1590,7 @@ public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bo * * @throws DatabaseException * - * @return false|int Number of rows inserted or FALSE on failure + * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode */ public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100) { @@ -1587,38 +1602,49 @@ public function insertBatch(?array $set = null, ?bool $escape = null, int $batch return false; // @codeCoverageIgnore } - } else { - if (empty($set)) { - if (CI_DEBUG) { - throw new DatabaseException('insertBatch() called with no data'); - } - - return false; // @codeCoverageIgnore + } elseif (empty($set)) { + if (CI_DEBUG) { + throw new DatabaseException('insertBatch() called with no data'); } - $this->setInsertBatch($set, '', $escape); + return false; // @codeCoverageIgnore } + $hasQBSet = $set === null; + $table = $this->QBFrom[0]; $affectedRows = 0; + $savedSQL = []; + + if ($hasQBSet) { + $set = $this->QBSet; + } - for ($i = 0, $total = count($this->QBSet); $i < $total; $i += $batchSize) { - $sql = $this->_insertBatch($this->db->protectIdentifiers($table, true, $escape, false), $this->QBKeys, array_slice($this->QBSet, $i, $batchSize)); + for ($i = 0, $total = count($set); $i < $total; $i += $batchSize) { + if ($hasQBSet) { + $QBSet = array_slice($this->QBSet, $i, $batchSize); + } else { + $this->setInsertBatch(array_slice($set, $i, $batchSize), '', $escape); + $QBSet = $this->QBSet; + } + $sql = $this->_insertBatch($this->db->protectIdentifiers($table, true, null, false), $this->QBKeys, $QBSet); if ($this->testMode) { - $affectedRows++; + $savedSQL[] = $sql; } else { - $this->db->query($sql, $this->binds, false); + $this->db->query($sql, null, false); $affectedRows += $this->db->affectedRows(); } - } - if (! $this->testMode) { - $this->resetWrite(); + if (! $hasQBSet) { + $this->resetWrite(); + } } - return $affectedRows; + $this->resetWrite(); + + return $this->testMode ? $savedSQL : $affectedRows; } /** @@ -1662,8 +1688,8 @@ public function setInsertBatch($key, string $value = '', ?bool $escape = null) $clean = []; - foreach ($row as $k => $rowValue) { - $clean[] = ':' . $this->setBind($k, $rowValue, $escape) . ':'; + foreach ($row as $rowValue) { + $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue; } $row = $clean; @@ -1672,7 +1698,7 @@ public function setInsertBatch($key, string $value = '', ?bool $escape = null) } foreach ($keys as $k) { - $this->QBKeys[] = $this->db->protectIdentifiers($k, false, $escape); + $this->QBKeys[] = $this->db->protectIdentifiers($k, false); } return $this; @@ -1929,7 +1955,7 @@ protected function validateUpdate(): bool * * @throws DatabaseException * - * @return mixed Number of rows affected, SQL string, or FALSE on failure + * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode */ public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100) { @@ -1949,28 +1975,37 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc return false; // @codeCoverageIgnore } - } else { - if (empty($set)) { - if (CI_DEBUG) { - throw new DatabaseException('updateBatch() called with no data'); - } - - return false; // @codeCoverageIgnore + } elseif (empty($set)) { + if (CI_DEBUG) { + throw new DatabaseException('updateBatch() called with no data'); } - $this->setUpdateBatch($set, $index); + return false; // @codeCoverageIgnore } + $hasQBSet = $set === null; + $table = $this->QBFrom[0]; $affectedRows = 0; $savedSQL = []; $savedQBWhere = $this->QBWhere; - for ($i = 0, $total = count($this->QBSet); $i < $total; $i += $batchSize) { + if ($hasQBSet) { + $set = $this->QBSet; + } + + for ($i = 0, $total = count($set); $i < $total; $i += $batchSize) { + if ($hasQBSet) { + $QBSet = array_slice($this->QBSet, $i, $batchSize); + } else { + $this->setUpdateBatch(array_slice($set, $i, $batchSize), $index); + $QBSet = $this->QBSet; + } + $sql = $this->_updateBatch( $table, - array_slice($this->QBSet, $i, $batchSize), + $QBSet, $this->db->protectIdentifiers($index) ); @@ -1981,6 +2016,10 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc $affectedRows += $this->db->affectedRows(); } + if (! $hasQBSet) { + $this->resetWrite(); + } + $this->QBWhere = $savedQBWhere; } @@ -2050,9 +2089,8 @@ public function setUpdateBatch($key, string $index = '', ?bool $escape = null) $indexSet = true; } - $bind = $this->setBind($k2, $v2, $escape); - - $clean[$this->db->protectIdentifiers($k2, false, $escape)] = ":{$bind}:"; + $clean[$this->db->protectIdentifiers($k2, false)] + = $escape ? $this->db->escape($v2) : $v2; } if ($indexSet === false) { @@ -2514,13 +2552,11 @@ protected function isLiteral(string $str): bool return true; } - static $_str; - - if (empty($_str)) { - $_str = ($this->db->escapeChar !== '"') ? ['"', "'"] : ["'"]; + if ($this->isLiteralStr === []) { + $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"]; } - return in_array($str[0], $_str, true); + return in_array($str[0], $this->isLiteralStr, true); } /** @@ -2599,7 +2635,10 @@ protected function resetWrite() */ protected function hasOperator(string $str): bool { - return (bool) preg_match('/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i', trim($str)); + return preg_match( + '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i', + trim($str) + ) === 1; } /** @@ -2609,11 +2648,11 @@ protected function hasOperator(string $str): bool */ protected function getOperator(string $str, bool $list = false) { - static $_operators; - - if (empty($_operators)) { - $_les = ($this->db->likeEscapeStr !== '') ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/') : ''; - $_operators = [ + if ($this->pregOperators === []) { + $_les = $this->db->likeEscapeStr !== '' + ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/') + : ''; + $this->pregOperators = [ '\s*(?:<|>|!)?=\s*', // =, <=, >=, != '\s*<>?\s*', // <, <> '\s*>\s*', // > @@ -2629,7 +2668,11 @@ protected function getOperator(string $str, bool $list = false) ]; } - return preg_match_all('/' . implode('|', $_operators) . '/i', $str, $match) ? ($list ? $match[0] : $match[0][0]) : false; + return preg_match_all( + '/' . implode('|', $this->pregOperators) . '/i', + $str, + $match + ) ? ($list ? $match[0] : $match[0][0]) : false; } /** diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 70bde67b6d50..ebb63b7f5827 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -238,6 +238,13 @@ abstract class BaseConnection implements ConnectionInterface */ public $likeEscapeChar = '!'; + /** + * RegExp used to escape identifiers + * + * @var array + */ + protected $pregEscapeChar = []; + /** * Holds previously looked up data * for performance reasons. @@ -1119,29 +1126,35 @@ public function escapeIdentifiers($item) return $item; } - static $pregEc = []; - - if (empty($pregEc)) { + if ($this->pregEscapeChar === []) { if (is_array($this->escapeChar)) { - $pregEc = [ + $this->pregEscapeChar = [ preg_quote($this->escapeChar[0], '/'), preg_quote($this->escapeChar[1], '/'), $this->escapeChar[0], $this->escapeChar[1], ]; } else { - $pregEc[0] = $pregEc[1] = preg_quote($this->escapeChar, '/'); - $pregEc[2] = $pregEc[3] = $this->escapeChar; + $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/'); + $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar; } } foreach ($this->reservedIdentifiers as $id) { if (strpos($item, '.' . $id) !== false) { - return preg_replace('/' . $pregEc[0] . '?([^' . $pregEc[1] . '\.]+)' . $pregEc[1] . '?\./i', $pregEc[2] . '$1' . $pregEc[3] . '.', $item); + return preg_replace( + '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i', + $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.', + $item + ); } } - return preg_replace('/' . $pregEc[0] . '?([^' . $pregEc[1] . '\.]+)' . $pregEc[1] . '?(\.)?/i', $pregEc[2] . '$1' . $pregEc[3] . '$2', $item); + return preg_replace( + '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i', + $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2', + $item + ); } /** @@ -1269,8 +1282,7 @@ protected function _escapeString(string $str): string */ public function callFunction(string $functionName, ...$params): bool { - $driver = strtolower($this->DBDriver); - $driver = ($driver === 'postgre' ? 'pg' : $driver) . '_'; + $driver = $this->getDriverFunctionPrefix(); if (strpos($driver, $functionName) === false) { $functionName = $driver . $functionName; @@ -1287,6 +1299,14 @@ public function callFunction(string $functionName, ...$params): bool return $functionName(...$params); } + /** + * Get the prefix of the function to access the DB. + */ + protected function getDriverFunctionPrefix(): string + { + return strtolower($this->DBDriver) . '_'; + } + //-------------------------------------------------------------------- // META Methods //-------------------------------------------------------------------- diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index faf83d854d7b..ba36915c79fe 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -144,7 +144,7 @@ abstract public function _getResult(); */ public function close() { - if (! is_object($this->statement)) { + if (! is_object($this->statement) || ! method_exists($this->statement, 'close')) { return; } diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index 3adbb592b24c..49a970f5bf11 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -28,7 +28,7 @@ abstract class BaseResult implements ResultInterface /** * Result ID * - * @var bool|object|resource + * @var false|object|resource */ public $resultID; diff --git a/system/Database/Forge.php b/system/Database/Forge.php index ba3423a890ea..8e0b7e43c7f3 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -160,6 +160,20 @@ class Forge */ protected $default = ' DEFAULT '; + /** + * DROP CONSTRAINT statement + * + * @var string + */ + protected $dropConstraintStr; + + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s ON %s'; + /** * Constructor. */ @@ -351,12 +365,25 @@ public function addField($field) throw new InvalidArgumentException('Field information is required for that operation.'); } - $this->fields[] = $field; + $fieldName = explode(' ', $field, 2)[0]; + $fieldName = trim($fieldName, '`\'"'); + + $this->fields[$fieldName] = $field; } } if (is_array($field)) { - $this->fields = array_merge($this->fields, $field); + foreach ($field as $idx => $f) { + if (is_string($f)) { + $this->addField($f); + + continue; + } + + if (is_array($f)) { + $this->fields = array_merge($this->fields, [$idx => $f]); + } + } } return $this; @@ -365,26 +392,68 @@ public function addField($field) /** * Add Foreign Key * + * @param string|string[] $fieldName + * @param string|string[] $tableField + * * @throws DatabaseException * * @return Forge */ - public function addForeignKey(string $fieldName = '', string $tableName = '', string $tableField = '', string $onUpdate = '', string $onDelete = '') + public function addForeignKey($fieldName = '', string $tableName = '', $tableField = '', string $onUpdate = '', string $onDelete = '') { - if (! isset($this->fields[$fieldName])) { - throw new DatabaseException(lang('Database.fieldNotExists', [$fieldName])); + $fieldName = (array) $fieldName; + $tableField = (array) $tableField; + $errorNames = []; + + foreach ($fieldName as $name) { + if (! isset($this->fields[$name])) { + $errorNames[] = $name; + } } - $this->foreignKeys[$fieldName] = [ - 'table' => $tableName, - 'field' => $tableField, - 'onDelete' => strtoupper($onDelete), - 'onUpdate' => strtoupper($onUpdate), + if ($errorNames !== []) { + $errorNames[0] = implode(', ', $errorNames); + + throw new DatabaseException(lang('Database.fieldNotExists', $errorNames)); + } + + $this->foreignKeys[] = [ + 'field' => $fieldName, + 'referenceTable' => $tableName, + 'referenceField' => $tableField, + 'onDelete' => strtoupper($onDelete), + 'onUpdate' => strtoupper($onUpdate), ]; return $this; } + /** + * Drop Key + * + * @throws DatabaseException + * + * @return bool + */ + public function dropKey(string $table, string $keyName) + { + $sql = sprintf( + $this->dropIndexStr, + $this->db->escapeIdentifiers($this->db->DBPrefix . $keyName), + $this->db->escapeIdentifiers($this->db->DBPrefix . $table), + ); + + if ($sql === '') { + if ($this->db->DBDebug) { + throw new DatabaseException('This feature is not available for the database you are using.'); + } + + return false; + } + + return $this->db->query($sql); + } + /** * @throws DatabaseException * @@ -398,7 +467,7 @@ public function dropForeignKey(string $table, string $foreignName) $this->db->escapeIdentifiers($this->db->DBPrefix . $foreignName) ); - if ($sql === false) { // @phpstan-ignore-line + if ($sql === '') { if ($this->db->DBDebug) { throw new DatabaseException('This feature is not available for the database you are using.'); } @@ -437,6 +506,8 @@ public function createTable(string $table, bool $ifNotExists = false, array $att return false; } + + return true; } if (($result = $this->db->query($sql)) !== false) { @@ -458,7 +529,7 @@ public function createTable(string $table, bool $ifNotExists = false, array $att } /** - * @return mixed + * @return bool|string */ protected function _createTable(string $table, bool $ifNotExists, array $attributes) { @@ -761,7 +832,7 @@ protected function _processFields(bool $createTable = false): array $fields = []; foreach ($this->fields as $key => $attributes) { - if (is_int($key) && ! is_array($attributes)) { + if (! is_array($attributes)) { $fields[] = ['_literal' => $attributes]; continue; @@ -996,11 +1067,15 @@ protected function _processForeignKeys(string $table): string 'SET DEFAULT', ]; - foreach ($this->foreignKeys as $field => $fkey) { - $nameIndex = $table . '_' . $field . '_foreign'; + foreach ($this->foreignKeys as $fkey) { + $nameIndex = $table . '_' . implode('_', $fkey['field']) . '_foreign'; + $nameIndexFilled = $this->db->escapeIdentifiers($nameIndex); + $foreignKeyFilled = implode(', ', $this->db->escapeIdentifiers($fkey['field'])); + $referenceTableFilled = $this->db->escapeIdentifiers($this->db->DBPrefix . $fkey['referenceTable']); + $referenceFieldFilled = implode(', ', $this->db->escapeIdentifiers($fkey['referenceField'])); - $sql .= ",\n\tCONSTRAINT " . $this->db->escapeIdentifiers($nameIndex) - . ' FOREIGN KEY(' . $this->db->escapeIdentifiers($field) . ') REFERENCES ' . $this->db->escapeIdentifiers($this->db->DBPrefix . $fkey['table']) . ' (' . $this->db->escapeIdentifiers($fkey['field']) . ')'; + $formatSql = ",\n\tCONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s)"; + $sql .= sprintf($formatSql, $nameIndexFilled, $foreignKeyFilled, $referenceTableFilled, $referenceFieldFilled); if ($fkey['onDelete'] !== false && in_array($fkey['onDelete'], $allowActions, true)) { $sql .= ' ON DELETE ' . $fkey['onDelete']; diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 756057528450..62881620c46b 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -58,6 +58,17 @@ class Connection extends BaseConnection */ public $mysqli; + /** + * MySQLi constant + * + * For unbuffered queries use `MYSQLI_USE_RESULT`. + * + * Default mode for buffered queries uses `MYSQLI_STORE_RESULT`. + * + * @var int + */ + public $resultMode = MYSQLI_STORE_RESULT; + /** * Connect to the database. * @@ -143,7 +154,6 @@ public function connect(bool $persistent = false) } } - $clientFlags += MYSQLI_CLIENT_SSL; $this->mysqli->ssl_set( $ssl['key'] ?? null, $ssl['cert'] ?? null, @@ -152,6 +162,8 @@ public function connect(bool $persistent = false) $ssl['cipher'] ?? null ); } + + $clientFlags += MYSQLI_CLIENT_SSL; } try { @@ -277,7 +289,7 @@ protected function execute(string $sql) } try { - return $this->connID->query($this->prepQuery($sql)); + return $this->connID->query($this->prepQuery($sql), $this->resultMode); } catch (mysqli_sql_exception $e) { log_message('error', $e->getMessage()); diff --git a/system/Database/MySQLi/Forge.php b/system/Database/MySQLi/Forge.php index 094ae54264f9..d00c26dd1ca7 100644 --- a/system/Database/MySQLi/Forge.php +++ b/system/Database/MySQLi/Forge.php @@ -220,4 +220,20 @@ protected function _processIndexes(string $table): string return $sql; } + + /** + * Drop Key + * + * @return bool + */ + public function dropKey(string $table, string $keyName) + { + $sql = sprintf( + $this->dropIndexStr, + $this->db->escapeIdentifiers($keyName), + $this->db->escapeIdentifiers($this->db->DBPrefix . $table), + ); + + return $this->db->query($sql); + } } diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 2b7a5b3dee06..1006ab20012e 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -39,8 +39,6 @@ class Builder extends BaseBuilder ]; /** - * Compile Ignore Statement - * * Checks if the ignore option is supported by * the Database Driver for the specific statement. * @@ -122,13 +120,11 @@ public function decrement(string $column, int $value = 1) * we simply do a DELETE and an INSERT on the first key/value * combo, assuming that it's either the primary key or a unique key. * - * @param array $set An associative array of insert values + * @param array|null $set An associative array of insert values * * @throws DatabaseException * * @return mixed - * - * @internal */ public function replace(?array $set = null) { @@ -145,18 +141,27 @@ public function replace(?array $set = null) } $table = $this->QBFrom[0]; + $set = $this->binds; + + array_walk($set, static function (array &$item) { + $item = $item[0]; + }); $key = array_key_first($set); $value = $set[$key]; $builder = $this->db->table($table); - $exists = $builder->where("{$key} = {$value}", null, false)->get()->getFirstRow(); + $exists = $builder->where($key, $value, true)->get()->getFirstRow(); - if (empty($exists)) { + if (empty($exists) && $this->testMode) { + $result = $this->getCompiledInsert(); + } elseif (empty($exists)) { $result = $builder->insert($set); + } elseif ($this->testMode) { + $result = $this->where($key, $value, true)->getCompiledUpdate(); } else { - array_pop($set); - $result = $builder->update($set, "{$key} = {$value}"); + array_shift($set); + $result = $builder->where($key, $value, true)->update($set); } unset($builder); diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 54b02dd1ca2c..a768e0ecf432 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -144,6 +144,14 @@ protected function execute(string $sql) return false; } + /** + * Get the prefix of the function to access the DB. + */ + protected function getDriverFunctionPrefix(): string + { + return 'pg_'; + } + /** * Returns the total number of rows affected by this query. */ diff --git a/system/Database/Postgre/Forge.php b/system/Database/Postgre/Forge.php index 2f1a0769bd07..a050c6888e38 100644 --- a/system/Database/Postgre/Forge.php +++ b/system/Database/Postgre/Forge.php @@ -32,6 +32,13 @@ class Forge extends BaseForge */ protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s'; + /** * UNSIGNED support * diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index 6f0fd9ac4ea1..76a0dd956515 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -68,7 +68,7 @@ public function getFieldData(): array */ public function freeResult() { - if (is_resource($this->resultID)) { + if ($this->resultID !== false) { pg_free_result($this->resultID); $this->resultID = false; } diff --git a/system/Database/Query.php b/system/Database/Query.php index 7f0873fdc40f..a4bb1b2b9de5 100644 --- a/system/Database/Query.php +++ b/system/Database/Query.php @@ -269,17 +269,19 @@ public function getOriginalQuery(): string /** * Escapes and inserts any binds into the finalQueryString object. * - * @see https://regex101.com/r/EUEhay/4 + * @see https://regex101.com/r/EUEhay/5 */ protected function compileBinds() { $sql = $this->finalQueryString; - $hasNamedBinds = preg_match('/:((?!=).+):/', $sql) === 1; + $hasBinds = strpos($sql, $this->bindMarker) !== false; + $hasNamedBinds = ! $hasBinds + && preg_match('/:(?!=).+:/', $sql) === 1; if (empty($this->binds) || empty($this->bindMarker) - || (! $hasNamedBinds && strpos($sql, $this->bindMarker) === false) + || (! $hasNamedBinds && ! $hasBinds) ) { return; } @@ -365,50 +367,57 @@ public function debugToolbarDisplay(): string { // Key words we want bolded static $highlight = [ - 'SELECT', + 'AND', + 'AS', + 'ASC', + 'AVG', + 'BY', + 'COUNT', + 'DESC', 'DISTINCT', 'FROM', - 'WHERE', - 'AND', - 'LEFT JOIN', - 'RIGHT JOIN', - 'JOIN', - 'ORDER BY', - 'GROUP BY', - 'LIMIT', - 'INSERT', - 'INTO', - 'VALUES', - 'UPDATE', - 'OR ', + 'GROUP', 'HAVING', - 'OFFSET', - 'NOT IN', 'IN', + 'INNER', + 'INSERT', + 'INTO', + 'IS', + 'JOIN', + 'LEFT', 'LIKE', - 'NOT LIKE', - 'COUNT', + 'LIMIT', 'MAX', 'MIN', + 'NOT', + 'NULL', + 'OFFSET', 'ON', - 'AS', - 'AVG', + 'OR', + 'ORDER', + 'RIGHT', + 'SELECT', 'SUM', - '(', - ')', + 'UPDATE', + 'VALUES', + 'WHERE', ]; if (empty($this->finalQueryString)) { $this->compileBinds(); // @codeCoverageIgnore } - $sql = $this->finalQueryString; + $sql = esc($this->finalQueryString); - foreach ($highlight as $term) { - $sql = str_replace($term, '' . $term . '', $sql); - } + /** + * @see https://stackoverflow.com/a/20767160 + * @see https://regex101.com/r/hUlrGN/4 + */ + $search = '/\b(?:' . implode('|', $highlight) . ')\b(?![^(')]*'(?:(?:[^(')]*'){2})*[^(')]*$)/'; - return $sql; + return preg_replace_callback($search, static function ($matches) { + return '' . str_replace(' ', ' ', $matches[0]) . ''; + }, $sql); } /** diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index cda4fac8a825..17d8bd576c9f 100755 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -170,6 +170,16 @@ protected function _insert(string $table, array $keys, array $unescapedKeys): st return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; } + /** + * Insert batch statement + * + * Generates a platform-specific insert string from the supplied data. + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $this->getFullName($table) . ' (' . implode(', ', $keys) . ') VALUES ' . implode(', ', $values); + } + /** * Generates a platform-specific update string from the supplied data */ @@ -183,12 +193,48 @@ protected function _update(string $table, array $values): string $fullTableName = $this->getFullName($table); - $statement = 'UPDATE ' . (empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ') . $fullTableName . ' SET ' - . implode(', ', $valstr) . $this->compileWhereHaving('QBWhere') . $this->compileOrderBy(); + $statement = sprintf('UPDATE %s%s SET ', empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName); + + $statement .= implode(', ', $valstr) + . $this->compileWhereHaving('QBWhere') + . $this->compileOrderBy(); return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; } + /** + * Update_Batch statement + * + * Generates a platform-specific batch update string from the supplied data + */ + protected function _updateBatch(string $table, array $values, string $index): string + { + $ids = []; + $final = []; + + foreach ($values as $val) { + $ids[] = $val[$index]; + + foreach (array_keys($val) as $field) { + if ($field !== $index) { + $final[$field][] = 'WHEN ' . $index . ' = ' . $val[$index] . ' THEN ' . $val[$field]; + } + } + } + + $cases = ''; + + foreach ($final as $k => $v) { + $cases .= $k . " = CASE \n" + . implode("\n", $v) . "\n" + . 'ELSE ' . $k . ' END, '; + } + + $this->where($index . ' IN(' . implode(',', $ids) . ')', null, false); + + return 'UPDATE ' . $this->compileIgnore('update') . ' ' . $this->getFullName($table) . ' SET ' . substr($cases, 0, -2) . $this->compileWhereHaving('QBWhere'); + } + /** * Increments a numeric column by the specified value. * @@ -203,6 +249,7 @@ public function increment(string $column, int $value = 1) } else { $values = [$column => "{$column} + {$value}"]; } + $sql = $this->_update($this->QBFrom[0], $values); return $this->db->query($sql, $this->binds, false); @@ -222,6 +269,7 @@ public function decrement(string $column, int $value = 1) } else { $values = [$column => "{$column} + {$value}"]; } + $sql = $this->_update($this->QBFrom[0], $values); return $this->db->query($sql, $this->binds, false); @@ -304,9 +352,10 @@ public function replace(?array $set = null) return $sql; } - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($table) . ' ON'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' ON'); + $result = $this->db->query($sql, $this->binds, false); - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($table) . ' OFF'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' OFF'); return $result; } @@ -348,9 +397,9 @@ protected function _replace(string $table, array $keys, array $values): string $bingo = []; foreach ($common as $v) { - $k = array_search($v, $escKeyFields, true); + $k = array_search($v, $keys, true); - $bingo[$keyFields[$k]] = $binds[trim($values[$k], ':')]; + $bingo[$keys[$k]] = $binds[trim($values[$k], ':')]; } // Querying existing data @@ -410,6 +459,40 @@ protected function maxMinAvgSum(string $select = '', string $alias = '', string return $this; } + /** + * "Count All" query + * + * Generates a platform-specific query string that counts all records in + * the particular table + * + * @param bool $reset Are we want to clear query builder values? + * + * @return int|string when $test = true + */ + public function countAll(bool $reset = true) + { + $table = $this->QBFrom[0]; + + $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' . $this->getFullName($table); + + if ($this->testMode) { + return $sql; + } + + $query = $this->db->query($sql, null, false); + if (empty($query->getResult())) { + return 0; + } + + $query = $query->getRow(); + + if ($reset === true) { + $this->resetSelect(); + } + + return (int) $query->numrows; + } + /** * Delete statement */ @@ -504,9 +587,10 @@ protected function compileSelect($selectOverride = false): string } $sql .= $this->compileWhereHaving('QBWhere') - . $this->compileGroupBy() - . $this->compileWhereHaving('QBHaving') - . $this->compileOrderBy(); // ORDER BY + . $this->compileGroupBy() + . $this->compileWhereHaving('QBHaving') + . $this->compileOrderBy(); // ORDER BY + // LIMIT if ($this->QBLimit) { $sql = $this->_limit($sql . "\n"); diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index f09a96d69213..3c86839d2e88 100755 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -215,7 +215,7 @@ protected function _listColumns(string $table = ''): string */ protected function _indexData(string $table): array { - $sql = 'EXEC sp_helpindex ' . $this->escape($table); + $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table); if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetIndexData')); diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 43131698703f..14d297604cbe 100755 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Database\SQLSRV; +use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Forge as BaseForge; /** @@ -23,7 +24,14 @@ class Forge extends BaseForge * * @var string */ - protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; + protected $dropConstraintStr; + + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr; /** * CREATE DATABASE IF statement @@ -61,7 +69,7 @@ class Forge extends BaseForge * * @var string */ - protected $renameTableStr = 'EXEC sp_rename %s , %s ;'; + protected $renameTableStr; /** * UNSIGNED support @@ -80,21 +88,33 @@ class Forge extends BaseForge * * @var string */ - protected $createTableIfStr = "IF NOT EXISTS (SELECT * FROM sysobjects WHERE ID = object_id(N'%s') AND OBJECTPROPERTY(id, N'IsUserTable') = 1)\nCREATE TABLE"; + protected $createTableIfStr; /** * CREATE TABLE statement * * @var string */ - protected $createTableStr = "%s %s (%s\n) "; + protected $createTableStr; - /** - * DROP TABLE IF statement - * - * @var string - */ - protected $_drop_table_if = "IF EXISTS (SELECT * FROM sysobjects WHERE ID = object_id(N'%s') AND OBJECTPROPERTY(id, N'IsUserTable') = 1)\nDROP TABLE"; + public function __construct(BaseConnection $db) + { + parent::__construct($db); + + $this->createTableIfStr = 'IF NOT EXISTS' + . '(SELECT t.name, s.name as schema_name, t.type_desc ' + . 'FROM sys.tables t ' + . 'INNER JOIN sys.schemas s on s.schema_id = t.schema_id ' + . "WHERE s.name=N'" . $this->db->schema . "' " + . "AND t.name=REPLACE(N'%s', '\"', '') " + . "AND t.type_desc='USER_TABLE')\nCREATE TABLE "; + + $this->createTableStr = '%s ' . $this->db->escapeIdentifiers($this->db->schema) . ".%s (%s\n) "; + $this->renameTableStr = 'EXEC sp_rename [' . $this->db->escapeIdentifiers($this->db->schema) . '.%s] , %s ;'; + + $this->dropConstraintStr = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s DROP CONSTRAINT %s'; + $this->dropIndexStr = 'DROP INDEX %s ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s'; + } /** * CREATE TABLE attributes @@ -111,9 +131,6 @@ protected function _createTableAttributes(array $attributes): string */ protected function _alterTable(string $alterType, string $table, $field) { - if ($alterType === 'ADD') { - return parent::_alterTable($alterType, $table, $field); - } // Handle DROP here if ($alterType === 'DROP') { @@ -133,7 +150,7 @@ protected function _alterTable(string $alterType, string $table, $field) } } - $sql = 'ALTER TABLE [' . $table . '] DROP '; + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) . ' DROP '; $fields = array_map(static function ($item) { return 'COLUMN [' . trim($item) . ']'; @@ -142,10 +159,19 @@ protected function _alterTable(string $alterType, string $table, $field) return $sql . implode(',', $fields); } - $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table); + $sql .= ($alterType === 'ADD') ? 'ADD ' : ' '; $sqls = []; + if ($alterType === 'ADD') { + foreach ($field as $data) { + $sqls[] = $sql . ($data['_literal'] !== false ? $data['_literal'] : $this->_processColumn($data)); + } + + return $sqls; + } + foreach ($field as $data) { if ($data['_literal'] !== false) { return false; @@ -198,6 +224,46 @@ protected function _dropIndex(string $table, object $indexData) return $this->db->simpleQuery($sql); } + /** + * Process indexes + * + * @return array|string + */ + protected function _processIndexes(string $table) + { + $sqls = []; + + for ($i = 0, $c = count($this->keys); $i < $c; $i++) { + $this->keys[$i] = (array) $this->keys[$i]; + + for ($i2 = 0, $c2 = count($this->keys[$i]); $i2 < $c2; $i2++) { + if (! isset($this->fields[$this->keys[$i][$i2]])) { + unset($this->keys[$i][$i2]); + } + } + + if (count($this->keys[$i]) <= 0) { + continue; + } + + if (in_array($i, $this->uniqueKeys, true)) { + $sqls[] = 'ALTER TABLE ' + . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' ADD CONSTRAINT ' . $this->db->escapeIdentifiers($table . '_' . implode('_', $this->keys[$i])) + . ' UNIQUE (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i])) . ');'; + + continue; + } + + $sqls[] = 'CREATE INDEX ' + . $this->db->escapeIdentifiers($table . '_' . implode('_', $this->keys[$i])) + . ' ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i])) . ');'; + } + + return $sqls; + } + /** * Process column */ @@ -224,12 +290,15 @@ protected function _processForeignKeys(string $table): string $allowActions = ['CASCADE', 'SET NULL', 'NO ACTION', 'RESTRICT', 'SET DEFAULT']; - foreach ($this->foreignKeys as $field => $fkey) { - $nameIndex = $table . '_' . $field . '_foreign'; + foreach ($this->foreignKeys as $fkey) { + $nameIndex = $table . '_' . implode('_', $fkey['field']) . '_foreign'; + $nameIndexFilled = $this->db->escapeIdentifiers($nameIndex); + $foreignKeyFilled = implode(', ', $this->db->escapeIdentifiers($fkey['field'])); + $referenceTableFilled = $this->db->escapeIdentifiers($this->db->DBPrefix . $fkey['referenceTable']); + $referenceFieldFilled = implode(', ', $this->db->escapeIdentifiers($fkey['referenceField'])); - $sql .= ",\n\t CONSTRAINT " . $this->db->escapeIdentifiers($nameIndex) - . ' FOREIGN KEY (' . $this->db->escapeIdentifiers($field) . ') ' - . ' REFERENCES ' . $this->db->escapeIdentifiers($this->db->getPrefix() . $fkey['table']) . ' (' . $this->db->escapeIdentifiers($fkey['field']) . ')'; + $formatSql = ",\n\tCONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s)"; + $sql .= sprintf($formatSql, $nameIndexFilled, $foreignKeyFilled, $referenceTableFilled, $referenceFieldFilled); if ($fkey['onDelete'] !== false && in_array($fkey['onDelete'], $allowActions, true)) { $sql .= ' ON DELETE ' . $fkey['onDelete']; @@ -245,8 +314,6 @@ protected function _processForeignKeys(string $table): string /** * Process primary keys - * - * @param string $table Table name */ protected function _processPrimaryKeys(string $table): string { @@ -293,6 +360,10 @@ protected function _attributeType(array &$attributes) $attributes['TYPE'] = 'DATETIME'; break; + case 'BOOLEAN': + $attributes['TYPE'] = 'BIT'; + break; + default: break; } diff --git a/system/Database/SQLSRV/Utils.php b/system/Database/SQLSRV/Utils.php index da4ac8679b9e..cf94d3dad783 100755 --- a/system/Database/SQLSRV/Utils.php +++ b/system/Database/SQLSRV/Utils.php @@ -12,6 +12,7 @@ namespace CodeIgniter\Database\SQLSRV; use CodeIgniter\Database\BaseUtils; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; /** @@ -33,6 +34,13 @@ class Utils extends BaseUtils */ protected $optimizeTable = 'ALTER INDEX all ON %s REORGANIZE'; + public function __construct(ConnectionInterface &$db) + { + parent::__construct($db); + + $this->optimizeTable = 'ALTER INDEX all ON ' . $this->db->schema . '.%s REORGANIZE'; + } + /** * Platform dependent version of the backup function. * diff --git a/system/Database/SQLite3/Forge.php b/system/Database/SQLite3/Forge.php index 4d118d04eb62..1f48f20bd54e 100644 --- a/system/Database/SQLite3/Forge.php +++ b/system/Database/SQLite3/Forge.php @@ -20,6 +20,13 @@ */ class Forge extends BaseForge { + /** + * DROP INDEX statement + * + * @var string + */ + protected $dropIndexStr = 'DROP INDEX %s'; + /** * @var Connection */ @@ -198,6 +205,10 @@ protected function _attributeType(array &$attributes) $attributes['TYPE'] = 'TEXT'; break; + case 'BOOLEAN': + $attributes['TYPE'] = 'INT'; + break; + default: break; } diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 5950148984ef..04556c5c4f20 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -19,7 +19,6 @@ use Config\Paths; use ErrorException; use Throwable; -use function error_reporting; /** * Exceptions manager @@ -64,17 +63,11 @@ class Exceptions */ protected $response; - /** - * Constructor. - */ public function __construct(ExceptionsConfig $config, IncomingRequest $request, Response $response) { $this->ob_level = ob_get_level(); - $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; - - $this->config = $config; - + $this->config = $config; $this->request = $request; $this->response = $response; } @@ -82,17 +75,13 @@ public function __construct(ExceptionsConfig $config, IncomingRequest $request, /** * Responsible for registering the error, exception and shutdown * handling of our application. + * + * @codeCoverageIgnore */ public function initialize() { - // Set the Exception Handler set_exception_handler([$this, 'exceptionHandler']); - - // Set the Error Handler set_error_handler([$this, 'errorHandler']); - - // Set the handler for shutdown to catch Parse errors - // Do we need this in PHP7? register_shutdown_function([$this, 'shutdownHandler']); } @@ -105,12 +94,8 @@ public function initialize() */ public function exceptionHandler(Throwable $exception) { - [ - $statusCode, - $exitCode, - ] = $this->determineCodes($exception); + [$statusCode, $exitCode] = $this->determineCodes($exception); - // Log it if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) { log_message('critical', $exception->getMessage() . "\n{trace}", [ 'trace' => $exception->getTraceAsString(), @@ -119,8 +104,7 @@ public function exceptionHandler(Throwable $exception) if (! is_cli()) { $this->response->setStatusCode($statusCode); - $header = "HTTP/{$this->request->getProtocolVersion()} {$this->response->getStatusCode()} {$this->response->getReason()}"; - header($header, true, $statusCode); + header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode); if (strpos($this->request->getHeaderLine('accept'), 'text/html') === false) { $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); @@ -142,6 +126,8 @@ public function exceptionHandler(Throwable $exception) * This seems to be primarily when a user triggers it with trigger_error(). * * @throws ErrorException + * + * @codeCoverageIgnore */ public function errorHandler(int $severity, string $message, ?string $file = null, ?int $line = null) { @@ -149,24 +135,27 @@ public function errorHandler(int $severity, string $message, ?string $file = nul return; } - // Convert it to an exception and pass it along. throw new ErrorException($message, 0, $severity, $file, $line); } /** * Checks to see if any errors have happened during shutdown that * need to be caught and handle them. + * + * @codeCoverageIgnore */ public function shutdownHandler() { $error = error_get_last(); - // If we've got an error that hasn't been displayed, then convert - // it to an Exception and use the Exception handler to display it - // to the user. - // Fatal Error? - if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { - $this->exceptionHandler(new ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line'])); + if ($error === null) { + return; + } + + ['type' => $type, 'message' => $message, 'file' => $file, 'line' => $line] = $error; + + if (in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { + $this->exceptionHandler(new ErrorException($message, $type, 0, $file, $line)); } } @@ -222,20 +211,25 @@ protected function render(Throwable $exception, int $statusCode) $viewFile = $altPath . $altView; } - // Prepare the vars - $vars = $this->collectVars($exception, $statusCode); - extract($vars); + if (! isset($viewFile)) { + echo 'The error view files were not found. Cannot render exception trace.'; + + exit(1); + } - // Render it if (ob_get_level() > $this->ob_level + 1) { ob_end_clean(); } - ob_start(); - include $viewFile; // @phpstan-ignore-line - $buffer = ob_get_contents(); - ob_end_clean(); - echo $buffer; + echo(function () use ($exception, $statusCode, $viewFile): string { + $vars = $this->collectVars($exception, $statusCode); + extract($vars, EXTR_SKIP); + + ob_start(); + include $viewFile; + + return ob_get_clean(); + })(); } /** @@ -244,7 +238,8 @@ protected function render(Throwable $exception, int $statusCode) protected function collectVars(Throwable $exception, int $statusCode): array { $trace = $exception->getTrace(); - if (! empty($this->config->sensitiveDataInTrace)) { + + if ($this->config->sensitiveDataInTrace !== []) { $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); } @@ -279,11 +274,11 @@ protected function maskSensitiveData(&$trace, array $keysToMask, string $path = } } - if (! is_iterable($trace) && is_object($trace)) { + if (is_object($trace)) { $trace = get_object_vars($trace); } - if (is_iterable($trace)) { + if (is_array($trace)) { foreach ($trace as $pathKey => $subarray) { $this->maskSensitiveData($subarray, $keysToMask, $path . '/' . $pathKey); } @@ -298,19 +293,18 @@ protected function determineCodes(Throwable $exception): array $statusCode = abs($exception->getCode()); if ($statusCode < 100 || $statusCode > 599) { - $exitStatus = $statusCode + EXIT__AUTO_MIN; // 9 is EXIT__AUTO_MIN - if ($exitStatus > EXIT__AUTO_MAX) { // 125 is EXIT__AUTO_MAX - $exitStatus = EXIT_ERROR; // EXIT_ERROR + $exitStatus = $statusCode + EXIT__AUTO_MIN; + + if ($exitStatus > EXIT__AUTO_MAX) { + $exitStatus = EXIT_ERROR; } + $statusCode = 500; } else { - $exitStatus = 1; // EXIT_ERROR + $exitStatus = EXIT_ERROR; } - return [ - $statusCode ?: 500, - $exitStatus, - ]; + return [$statusCode, $exitStatus]; } //-------------------------------------------------------------------- @@ -318,8 +312,6 @@ protected function determineCodes(Throwable $exception): array //-------------------------------------------------------------------- /** - * Clean Path - * * This makes nicer looking paths for the error output. */ public static function cleanPath(string $file): string @@ -354,6 +346,7 @@ public static function describeMemory(int $bytes): string if ($bytes < 1024) { return $bytes . 'B'; } + if ($bytes < 1048576) { return round($bytes / 1024, 2) . 'KB'; } @@ -390,18 +383,16 @@ public static function highlightFile(string $file, int $lineNumber, int $lines = $source = str_replace(["\r\n", "\r"], "\n", $source); $source = explode("\n", highlight_string($source, true)); $source = str_replace('
', "\n", $source[1]); - $source = explode("\n", str_replace("\r\n", "\n", $source)); // Get just the part to show - $start = $lineNumber - (int) round($lines / 2); - $start = $start < 0 ? 0 : $start; + $start = max($lineNumber - (int) round($lines / 2), 0); // Get just the lines we need to display, while keeping line numbers... $source = array_splice($source, $start, $lines, true); // @phpstan-ignore-line // Used to format the line number in the source - $format = '% ' . strlen(sprintf('%s', $start + $lines)) . 'd'; + $format = '% ' . strlen((string) ($start + $lines)) . 'd'; $out = ''; // Because the highlighting may have an uneven number @@ -412,11 +403,11 @@ public static function highlightFile(string $file, int $lineNumber, int $lines = foreach ($source as $n => $row) { $spans += substr_count($row, ']+>#', $row, $tags); + $out .= sprintf( "{$format} %s\n%s", $n + $start + 1, diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 16432054d2e2..b951e2ed9658 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -149,7 +149,7 @@ public function run(float $startTime, float $totalTime, RequestInterface $reques $data['vars']['response'] = [ 'statusCode' => $response->getStatusCode(), - 'reason' => esc($response->getReason()), + 'reason' => esc($response->getReasonPhrase()), 'contentType' => esc($response->getHeaderLine('content-type')), ]; @@ -166,17 +166,39 @@ public function run(float $startTime, float $totalTime, RequestInterface $reques * Called within the view to display the timeline itself. */ protected function renderTimeline(array $collectors, float $startTime, int $segmentCount, int $segmentDuration, array &$styles): string + { + $rows = $this->collectTimelineData($collectors); + $styleCount = 0; + + // Use recursive render function + return $this->renderTimelineRecursive($rows, $startTime, $segmentCount, $segmentDuration, $styles, $styleCount); + } + + /** + * Recursively renders timeline elements and their children. + */ + protected function renderTimelineRecursive(array $rows, float $startTime, int $segmentCount, int $segmentDuration, array &$styles, int &$styleCount, int $level = 0, bool $isChild = false): string { $displayTime = $segmentCount * $segmentDuration; - $rows = $this->collectTimelineData($collectors); - $output = ''; - $styleCount = 0; + + $output = ''; foreach ($rows as $row) { - $output .= ''; - $output .= "{$row['name']}"; - $output .= "{$row['component']}"; - $output .= "" . number_format($row['duration'] * 1000, 2) . ' ms'; + $hasChildren = isset($row['children']) && ! empty($row['children']); + $isQuery = isset($row['query']) && ! empty($row['query']); + + // Open controller timeline by default + $open = $row['name'] === 'Controller'; + + if ($hasChildren || $isQuery) { + $output .= ''; + } else { + $output .= ''; + } + + $output .= '' . ($hasChildren || $isQuery ? '' : '') . $row['name'] . ''; + $output .= '' . $row['component'] . ''; + $output .= '' . number_format($row['duration'] * 1000, 2) . ' ms'; $output .= ""; $offset = ((((float) $row['start'] - $startTime) * 1000) / $displayTime) * 100; @@ -189,6 +211,29 @@ protected function renderTimeline(array $collectors, float $startTime, int $segm $output .= ''; $styleCount++; + + // Add children if any + if ($hasChildren || $isQuery) { + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + + if ($isQuery) { + // Output query string if query + $output .= ''; + $output .= ''; + $output .= ''; + } else { + // Recursively render children + $output .= $this->renderTimelineRecursive($row['children'], $startTime, $segmentCount, $segmentDuration, $styles, $styleCount, $level + 1, true); + } + + $output .= ''; + $output .= '
' . $row['query'] . '
'; + $output .= ''; + $output .= ''; + } } return $output; @@ -213,10 +258,52 @@ protected function collectTimelineData($collectors): array } // Sort it + $sortArray = [ + array_column($data, 'start'), SORT_NUMERIC, SORT_ASC, + array_column($data, 'duration'), SORT_NUMERIC, SORT_DESC, + &$data, + ]; + + array_multisort(...$sortArray); + + // Add end time to each element + array_walk($data, static function (&$row) { + $row['end'] = $row['start'] + $row['duration']; + }); + + // Group it + $data = $this->structureTimelineData($data); return $data; } + /** + * Arranges the already sorted timeline data into a parent => child structure. + */ + protected function structureTimelineData(array $elements): array + { + // We define ourselves as the first element of the array + $element = array_shift($elements); + + // If we have children behind us, collect and attach them to us + while (! empty($elements) && $elements[array_key_first($elements)]['end'] <= $element['end']) { + $element['children'][] = array_shift($elements); + } + + // Make sure our children know whether they have children, too + if (isset($element['children'])) { + $element['children'] = $this->structureTimelineData($element['children']); + } + + // If we have no younger siblings, we can return + if (empty($elements)) { + return [$element]; + } + + // Make sure our younger siblings know their relatives, too + return array_merge([$element], $this->structureTimelineData($elements)); + } + /** * Returns an array of data from all of the modules * that should be displayed in the 'Vars' tab. @@ -331,6 +418,8 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r /** * Inject debug toolbar into the response. + * + * @codeCoverageIgnore */ public function respond() { @@ -338,20 +427,20 @@ public function respond() return; } - // @codeCoverageIgnoreStart $request = Services::request(); // If the request contains '?debugbar then we're // simply returning the loading script if ($request->getGet('debugbar') !== null) { - // Let the browser know that we are sending javascript header('Content-Type: application/javascript'); ob_start(); - include $this->config->viewsPath . 'toolbarloader.js.php'; + include $this->config->viewsPath . 'toolbarloader.js'; $output = ob_get_clean(); + $output = str_replace('{url}', rtrim(site_url(), '/'), $output); + echo $output; - exit($output); + exit; } // Otherwise, if it includes ?debugbar_time, then @@ -360,29 +449,24 @@ public function respond() helper('security'); // Negotiate the content-type to format the output - $format = $request->negotiate('media', [ - 'text/html', - 'application/json', - 'application/xml', - ]); + $format = $request->negotiate('media', ['text/html', 'application/json', 'application/xml']); $format = explode('/', $format)[1]; - $file = sanitize_filename('debugbar_' . $request->getGet('debugbar_time')); - $filename = WRITEPATH . 'debugbar/' . $file . '.json'; + $filename = sanitize_filename('debugbar_' . $request->getGet('debugbar_time')); + $filename = WRITEPATH . 'debugbar/' . $filename . '.json'; - // Show the toolbar if (is_file($filename)) { - $contents = $this->format(file_get_contents($filename), $format); + // Show the toolbar if it exists + echo $this->format(file_get_contents($filename), $format); - exit($contents); + exit; } - // File was not written or do not exists + // Filename not found http_response_code(404); - exit; // Exit here is needed to avoid load the index page + exit; // Exit here is needed to avoid loading the index page } - // @codeCoverageIgnoreEnd } /** diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php index 6fd5b263f4a3..d8445b80364f 100644 --- a/system/Debug/Toolbar/Collectors/Database.php +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -57,7 +57,7 @@ class Database extends BaseCollector * The query instances that have been collected * through the DBQuery Event. * - * @var Query[] + * @var array */ protected static $queries = []; @@ -66,7 +66,7 @@ class Database extends BaseCollector */ public function __construct() { - $this->connections = \Config\Database::getConnections(); + $this->getConnections(); } /** @@ -83,7 +83,13 @@ public static function collect(Query $query) $max = $config->maxQueries ?: 100; if (count(static::$queries) < $max) { - static::$queries[] = $query; + $queryString = $query->getQuery(); + + static::$queries[] = [ + 'query' => $query, + 'string' => $queryString, + 'duplicate' => in_array($queryString, array_column(static::$queries, 'string', null), true), + ]; } } @@ -110,8 +116,9 @@ protected function formatTimelineData(): array $data[] = [ 'name' => 'Query', 'component' => 'Database', - 'start' => $query->getStartTime(true), - 'duration' => $query->getDuration(), + 'start' => $query['query']->getStartTime(true), + 'duration' => $query['query']->getDuration(), + 'query' => $query['query']->debugToolbarDisplay(), ]; } @@ -123,10 +130,14 @@ protected function formatTimelineData(): array */ public function display(): array { - $data['queries'] = array_map(static function (Query $query) { + $data['queries'] = array_map(static function (array $query) { + $isDuplicate = $query['duplicate'] === true; + return [ - 'duration' => ((float) $query->getDuration(5) * 1000) . ' ms', - 'sql' => $query->debugToolbarDisplay(), + 'hover' => $isDuplicate ? 'This query was called more than once.' : '', + 'class' => $isDuplicate ? 'duplicate' : '', + 'duration' => ((float) $query['query']->getDuration(5) * 1000) . ' ms', + 'sql' => $query['query']->debugToolbarDisplay(), ]; }, static::$queries); @@ -148,8 +159,23 @@ public function getBadgeValue(): int */ public function getTitleDetails(): string { - return '(' . count(static::$queries) . ' Queries across ' . ($countConnection = count($this->connections)) . ' Connection' . - ($countConnection > 1 ? 's' : '') . ')'; + $this->getConnections(); + + $queryCount = count(static::$queries); + $uniqueCount = count(array_filter(static::$queries, static function ($query) { + return $query['duplicate'] === false; + })); + $connectionCount = count($this->connections); + + return sprintf( + '(%d total Quer%s, %d %s unique across %d Connection%s)', + $queryCount, + $queryCount > 1 ? 'ies' : 'y', + $uniqueCount, + $uniqueCount > 1 ? 'of them' : '', + $connectionCount, + $connectionCount > 1 ? 's' : '' + ); } /** @@ -169,4 +195,12 @@ public function icon(): string { return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADMSURBVEhLY6A3YExLSwsA4nIycQDIDIhRWEBqamo/UNF/SjDQjF6ocZgAKPkRiFeEhoYyQ4WIBiA9QAuWAPEHqBAmgLqgHcolGQD1V4DMgHIxwbCxYD+QBqcKINseKo6eWrBioPrtQBq/BcgY5ht0cUIYbBg2AJKkRxCNWkDQgtFUNJwtABr+F6igE8olGQD114HMgHIxAVDyAhA/AlpSA8RYUwoeXAPVex5qHCbIyMgwBCkAuQJIY00huDBUz/mUlBQDqHGjgBjAwAAACexpph6oHSQAAAAASUVORK5CYII='; } + + /** + * Gets the connections from the database config + */ + private function getConnections() + { + $this->connections = \Config\Database::getConnections(); + } } diff --git a/system/Debug/Toolbar/Views/_database.tpl b/system/Debug/Toolbar/Views/_database.tpl index b5cf1a43a7d7..a373b56b5256 100644 --- a/system/Debug/Toolbar/Views/_database.tpl +++ b/system/Debug/Toolbar/Views/_database.tpl @@ -7,7 +7,7 @@ {queries} - + {duration} {! sql !} diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index f881dd18d6ec..b5a223b55d70 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -1,9 +1,10 @@ -/*! CodeIgniter 4 - Debug bar - * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com - * Website: https://codeigniter.com +/** + * This file is part of the CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ #debug-icon { bottom: 0; @@ -117,7 +118,6 @@ overflow: hidden; overflow-y: auto; padding: 0 12px 0 12px; - /* give room for OS X scrollbar */ white-space: nowrap; z-index: 10000; } #debug-bar.fixed-top { @@ -200,7 +200,14 @@ padding: 5px; position: relative; } #debug-bar .timeline td:first-child { - border-left: 0; } + border-left: 0; + max-width: none; } + #debug-bar .timeline td.child-container { + padding: 0px; } + #debug-bar .timeline td.child-container .timeline { + margin: 0px; } + #debug-bar .timeline td.child-container .timeline td:first-child:not(.child-container) { + padding-left: calc(5px + 10px * var(--level)); } #debug-bar .timeline .timer { border-radius: 4px; -moz-border-radius: 4px; @@ -209,6 +216,22 @@ padding: 5px; position: absolute; top: 30%; } + #debug-bar .timeline .timeline-parent { + cursor: pointer; } + #debug-bar .timeline .timeline-parent td:first-child nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; } + #debug-bar .timeline .timeline-parent-open { + background-color: #DFDFDF; } + #debug-bar .timeline .timeline-parent-open td:first-child nav { + background-position: 0 75%; } + #debug-bar .timeline .child-row:hover { + background: transparent; } #debug-bar .route-params, #debug-bar .route-params-item { vertical-align: top; } @@ -244,7 +267,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #debug-icon a:active, #debug-icon a:link, #debug-icon a:visited { + #debug-icon a:active, + #debug-icon a:link, + #debug-icon a:visited { color: #DD8615; } #debug-bar { @@ -267,7 +292,7 @@ #debug-bar button { background-color: #FFFFFF; } #debug-bar table strong { - color: #FDC894; } + color: #DD8615; } #debug-bar table tbody tr:hover { background-color: #DFDFDF; } #debug-bar table tbody tr.current { @@ -330,7 +355,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #debug-icon a:active, #debug-icon a:link, #debug-icon a:visited { + #debug-icon a:active, + #debug-icon a:link, + #debug-icon a:visited { color: #DD8615; } #debug-bar { background-color: #252525; @@ -352,7 +379,7 @@ #debug-bar button { background-color: #252525; } #debug-bar table strong { - color: #FDC894; } + color: #DD8615; } #debug-bar table tbody tr:hover { background-color: #434343; } #debug-bar table tbody tr.current { @@ -414,7 +441,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #toolbarContainer.dark #debug-icon a:active, #toolbarContainer.dark #debug-icon a:link, #toolbarContainer.dark #debug-icon a:visited { + #toolbarContainer.dark #debug-icon a:active, + #toolbarContainer.dark #debug-icon a:link, + #toolbarContainer.dark #debug-icon a:visited { color: #DD8615; } #toolbarContainer.dark #debug-bar { @@ -437,11 +466,13 @@ #toolbarContainer.dark #debug-bar button { background-color: #252525; } #toolbarContainer.dark #debug-bar table strong { - color: #FDC894; } + color: #DD8615; } #toolbarContainer.dark #debug-bar table tbody tr:hover { background-color: #434343; } #toolbarContainer.dark #debug-bar table tbody tr.current { background-color: #FDC894; } + #toolbarContainer.dark #ci-database table tbody tr.duplicate { + background-color: #434343;} #toolbarContainer.dark #debug-bar table tbody tr.current td { color: #252525; } #toolbarContainer.dark #debug-bar table tbody tr.current:hover td { @@ -505,7 +536,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #toolbarContainer.light #debug-icon a:active, #toolbarContainer.light #debug-icon a:link, #toolbarContainer.light #debug-icon a:visited { + #toolbarContainer.light #debug-icon a:active, + #toolbarContainer.light #debug-icon a:link, + #toolbarContainer.light #debug-icon a:visited { color: #DD8615; } #toolbarContainer.light #debug-bar { @@ -528,11 +561,13 @@ #toolbarContainer.light #debug-bar button { background-color: #FFFFFF; } #toolbarContainer.light #debug-bar table strong { - color: #FDC894; } + color: #DD8615; } #toolbarContainer.light #debug-bar table tbody tr:hover { background-color: #DFDFDF; } #toolbarContainer.light #debug-bar table tbody tr.current { background-color: #FDC894; } + #toolbarContainer.light #ci-database table tbody tr.duplicate { + background-color: #DFDFDF;} #toolbarContainer.light #debug-bar table tbody tr.current:hover td { background-color: #DD4814; color: #FFFFFF; } diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js index 563cf21cd682..690535f2de0d 100644 --- a/system/Debug/Toolbar/Views/toolbar.js +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -141,6 +141,28 @@ var ciDebugBar = { } }, + /** + * Toggle display of timeline child elements + * + * @param obj + */ + toggleChildRows : function (obj) { + if (typeof obj == 'string') + { + par = document.getElementById(obj + '_parent') + obj = document.getElementById(obj + '_children'); + } + + if (par && obj) + { + obj.style.display = obj.style.display == 'none' ? '' : 'none'; + par.classList.toggle('timeline-parent-open'); + } + }, + + + //-------------------------------------------------------------------- + /** * Toggle tool bar from full to icon and icon to full */ diff --git a/system/Debug/Toolbar/Views/toolbar.tpl.php b/system/Debug/Toolbar/Views/toolbar.tpl.php index 1c84ee541bd6..65cf2a4c3edf 100644 --- a/system/Debug/Toolbar/Views/toolbar.tpl.php +++ b/system/Debug/Toolbar/Views/toolbar.tpl.php @@ -265,10 +265,7 @@ diff --git a/system/Debug/Toolbar/Views/toolbarloader.js b/system/Debug/Toolbar/Views/toolbarloader.js new file mode 100644 index 000000000000..7e5914354481 --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbarloader.js @@ -0,0 +1,87 @@ +document.addEventListener('DOMContentLoaded', loadDoc, false); + +function loadDoc(time) { + if (isNaN(time)) { + time = document.getElementById("debugbar_loader").getAttribute("data-time"); + localStorage.setItem('debugbar-time', time); + } + + localStorage.setItem('debugbar-time-new', time); + + let url = '{url}'; + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + let toolbar = document.getElementById("toolbarContainer"); + + if (! toolbar) { + toolbar = document.createElement('div'); + toolbar.setAttribute('id', 'toolbarContainer'); + document.body.appendChild(toolbar); + } + + let responseText = this.responseText; + let dynamicStyle = document.getElementById('debugbar_dynamic_style'); + let dynamicScript = document.getElementById('debugbar_dynamic_script'); + + // get the first style block, copy contents to dynamic_style, then remove here + let start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicStyle.innerHTML = responseText.substr(start, end - start); + responseText = responseText.substr(end + 8); + + // get the first script after the first style, copy contents to dynamic_script, then remove here + start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicScript.innerHTML = responseText.substr(start, end - start); + responseText = responseText.substr(end + 9); + + // check for last style block, append contents to dynamic_style, then remove here + start = responseText.indexOf('>', responseText.indexOf('', start); + dynamicStyle.innerHTML += responseText.substr(start, end - start); + responseText = responseText.substr(0, start - 8); + + toolbar.innerHTML = responseText; + + if (typeof ciDebugBar === 'object') { + ciDebugBar.init(); + } + } else if (this.readyState === 4 && this.status === 404) { + console.log('CodeIgniter DebugBar: File "WRITEPATH/debugbar/debugbar_' + time + '" not found.'); + } + }; + + xhttp.open("GET", url + "?debugbar_time=" + time, true); + xhttp.send(); +} + +window.oldXHR = window.ActiveXObject + ? new ActiveXObject('Microsoft.XMLHTTP') + : window.XMLHttpRequest; + +function newXHR() { + const realXHR = new window.oldXHR(); + + realXHR.addEventListener("readystatechange", function() { + // Only success responses and URLs that do not contains "debugbar_time" are tracked + if (realXHR.readyState === 4 && realXHR.status.toString()[0] === '2' && realXHR.responseURL.indexOf('debugbar_time') === -1) { + if (realXHR.getAllResponseHeaders().indexOf("Debugbar-Time") >= 0) { + let debugbarTime = realXHR.getResponseHeader('Debugbar-Time'); + + if (debugbarTime) { + let h2 = document.querySelector('#ci-history > h2'); + + if (h2) { + h2.innerHTML = 'History You have new debug data. '; + document.querySelector('a[data-tab="ci-history"] > span > .badge').className += ' active'; + } + } + } + } + }, false); + return realXHR; +} + +window.XMLHttpRequest = newXHR; diff --git a/system/Debug/Toolbar/Views/toolbarloader.js.php b/system/Debug/Toolbar/Views/toolbarloader.js.php deleted file mode 100644 index 70242b5620bb..000000000000 --- a/system/Debug/Toolbar/Views/toolbarloader.js.php +++ /dev/null @@ -1,93 +0,0 @@ - -document.addEventListener('DOMContentLoaded', loadDoc, false); - -function loadDoc(time) { - if (isNaN(time)) { - time = document.getElementById("debugbar_loader").getAttribute("data-time"); - localStorage.setItem('debugbar-time', time); - } - - localStorage.setItem('debugbar-time-new', time); - - var url = ""; - - var xhttp = new XMLHttpRequest(); - xhttp.onreadystatechange = function() { - if (this.readyState === 4 && this.status === 200) { - var toolbar = document.getElementById("toolbarContainer"); - if (!toolbar) { - toolbar = document.createElement('div'); - toolbar.setAttribute('id', 'toolbarContainer'); - document.body.appendChild(toolbar); - } - - // copy for easier manipulation - let responseText = this.responseText; - - // get csp blocked parts - // the style block is the first and starts at 0 - { - let PosBeg = responseText.indexOf( '>', responseText.indexOf( '', PosBeg ); - document.getElementById( 'debugbar_dynamic_style' ).innerHTML = responseText.substr( PosBeg, PosEnd - PosBeg ); - responseText = responseText.substr( PosEnd + 8 ); - } - // the script block starts right after style blocks ended - { - let PosBeg = responseText.indexOf( '>', responseText.indexOf( '' ); - document.getElementById( 'debugbar_dynamic_script' ).innerHTML = responseText.substr( PosBeg, PosEnd - PosBeg ); - responseText = responseText.substr( PosEnd + 9 ); - } - // check for last style block - { - let PosBeg = responseText.indexOf( '>', responseText.lastIndexOf( '', PosBeg ); - document.getElementById( 'debugbar_dynamic_style' ).innerHTML += responseText.substr( PosBeg, PosEnd - PosBeg ); - responseText = responseText.substr( 0, PosBeg + 8 ); - } - - toolbar.innerHTML = responseText; - if (typeof ciDebugBar === 'object') { - ciDebugBar.init(); - } - } else if (this.readyState === 4 && this.status === 404) { - console.log('CodeIgniter DebugBar: File "WRITEPATH/debugbar/debugbar_' + time + '" not found.'); - } - }; - - xhttp.open("GET", url + "?debugbar_time=" + time, true); - xhttp.send(); -} - -// Track all AJAX requests -if (window.ActiveXObject) { - var oldXHR = new ActiveXObject('Microsoft.XMLHTTP'); -} else { - var oldXHR = window.XMLHttpRequest; -} - -function newXHR() { - var realXHR = new oldXHR(); - realXHR.addEventListener("readystatechange", function() { - // Only success responses and URLs that do not contains "debugbar_time" are tracked - if (realXHR.readyState === 4 && realXHR.status.toString()[0] === '2' && realXHR.responseURL.indexOf('debugbar_time') === -1) { - if (realXHR.getAllResponseHeaders().indexOf("Debugbar-Time") >= 0) { - var debugbarTime = realXHR.getResponseHeader('Debugbar-Time'); - - if (debugbarTime) { - var h2 = document.querySelector('#ci-history > h2'); - if (h2) { - h2.innerHTML = 'History You have new debug data. '; - var badge = document.querySelector('a[data-tab="ci-history"] > span > .badge'); - badge.className += ' active'; - } - } - } - } - }, false); - return realXHR; -} - -window.XMLHttpRequest = newXHR; - diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 7d44adf3e14c..a98395172e23 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -41,7 +41,7 @@ class Entity implements JsonSerializable * * Example: * $datamap = [ - * 'db_name' => 'class_name' + * 'class_name' => 'db_name' * ]; */ protected $datamap = []; diff --git a/system/Exceptions/PageNotFoundException.php b/system/Exceptions/PageNotFoundException.php index 444776c087a2..2a773f1785dc 100644 --- a/system/Exceptions/PageNotFoundException.php +++ b/system/Exceptions/PageNotFoundException.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Exceptions; +use Config\Services; use OutOfBoundsException; class PageNotFoundException extends OutOfBoundsException implements ExceptionInterface @@ -26,21 +27,37 @@ class PageNotFoundException extends OutOfBoundsException implements ExceptionInt public static function forPageNotFound(?string $message = null) { - return new static($message ?? lang('HTTP.pageNotFound')); + return new static($message ?? self::lang('HTTP.pageNotFound')); } public static function forEmptyController() { - return new static(lang('HTTP.emptyController')); + return new static(self::lang('HTTP.emptyController')); } public static function forControllerNotFound(string $controller, string $method) { - return new static(lang('HTTP.controllerNotFound', [$controller, $method])); + return new static(self::lang('HTTP.controllerNotFound', [$controller, $method])); } public static function forMethodNotFound(string $method) { - return new static(lang('HTTP.methodNotFound', [$method])); + return new static(self::lang('HTTP.methodNotFound', [$method])); + } + + /** + * Get translated system message + * + * Use a non-shared Language instance in the Services. + * If a shared instance is created, the Language will + * have the current locale, so even if users call + * `$this->request->setLocale()` in the controller afterwards, + * the Language locale will not be changed. + */ + private static function lang(string $line, array $args = []): string + { + $lang = Services::language(null, false); + + return $lang->getLine($line, $args); } } diff --git a/system/Exceptions/TestException.php b/system/Exceptions/TestException.php new file mode 100644 index 000000000000..a1c4c51cf814 --- /dev/null +++ b/system/Exceptions/TestException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Exception for automatic logging. + */ +class TestException extends CriticalError +{ + use DebugTraceableTrait; + + public static function forInvalidMockClass(string $name) + { + return new static(lang('Test.invalidMockClass', [$name])); + } +} diff --git a/system/Files/Exceptions/FileException.php b/system/Files/Exceptions/FileException.php index bbbdc1a3cbb1..03af1bc57b08 100644 --- a/system/Files/Exceptions/FileException.php +++ b/system/Files/Exceptions/FileException.php @@ -23,4 +23,24 @@ public static function forUnableToMove(?string $from = null, ?string $to = null, { return new static(lang('Files.cannotMove', [$from, $to, $error])); } + + /** + * Throws when an item is expected to be a directory but is not or is missing. + * + * @param string $caller The method causing the exception + */ + public static function forExpectedDirectory(string $caller) + { + return new static(lang('Files.expectedDirectory', [$caller])); + } + + /** + * Throws when an item is expected to be a file but is not or is missing. + * + * @param string $caller The method causing the exception + */ + public static function forExpectedFile(string $caller) + { + return new static(lang('Files.expectedFile', [$caller])); + } } diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php new file mode 100644 index 000000000000..0b1714425ba9 --- /dev/null +++ b/system/Files/FileCollection.php @@ -0,0 +1,367 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Files; + +use CodeIgniter\Files\Exceptions\FileException; +use CodeIgniter\Files\Exceptions\FileNotFoundException; +use Countable; +use Generator; +use InvalidArgumentException; +use IteratorAggregate; + +/** + * File Collection Class + * + * Representation for a group of files, with utilities for locating, + * filtering, and ordering them. + */ +class FileCollection implements Countable, IteratorAggregate +{ + /** + * The current list of file paths. + * + * @var string[] + */ + protected $files = []; + + //-------------------------------------------------------------------- + // Support Methods + //-------------------------------------------------------------------- + + /** + * Resolves a full path and verifies it is an actual directory. + * + * @throws FileException + */ + final protected static function resolveDirectory(string $directory): string + { + if (! is_dir($directory = set_realpath($directory))) { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + + throw FileException::forExpectedDirectory($caller['function']); + } + + return $directory; + } + + /** + * Resolves a full path and verifies it is an actual file. + * + * @throws FileException + */ + final protected static function resolveFile(string $file): string + { + if (! is_file($file = set_realpath($file))) { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + + throw FileException::forExpectedFile($caller['function']); + } + + return $file; + } + + /** + * Removes files that are not part of the given directory (recursive). + * + * @param string[] $files + * + * @return string[] + */ + final protected static function filterFiles(array $files, string $directory): array + { + $directory = self::resolveDirectory($directory); + + return array_filter($files, static function (string $value) use ($directory): bool { + return strpos($value, $directory) === 0; + }); + } + + /** + * Returns any files whose `basename` matches the given pattern. + * + * @param string[] $files + * @param string $pattern Regex or pseudo-regex string + * + * @return string[] + */ + final protected static function matchFiles(array $files, string $pattern): array + { + // Convert pseudo-regex into their true form + if (@preg_match($pattern, '') === false) { + $pattern = str_replace( + ['#', '.', '*', '?'], + ['\#', '\.', '.*', '.'], + $pattern + ); + $pattern = "#{$pattern}#"; + } + + return array_filter($files, static function ($value) use ($pattern) { + return (bool) preg_match($pattern, basename($value)); + }); + } + + //-------------------------------------------------------------------- + // Class Core + //-------------------------------------------------------------------- + + /** + * Loads the Filesystem helper and adds any initial files. + * + * @param string[] $files + */ + public function __construct(array $files = []) + { + helper(['filesystem']); + + $this->add($files)->define(); + } + + /** + * Applies any initial inputs after the constructor. + * This method is a stub to be implemented by child classes. + */ + protected function define(): void + { + } + + /** + * Optimizes and returns the current file list. + * + * @return string[] + */ + public function get(): array + { + $this->files = array_unique($this->files); + sort($this->files, SORT_STRING); + + return $this->files; + } + + /** + * Sets the file list directly, files are still subject to verification. + * This works as a "reset" method with []. + * + * @param string[] $files The new file list to use + * + * @return $this + */ + public function set(array $files) + { + $this->files = []; + + return $this->addFiles($files); + } + + /** + * Adds an array/single file or directory to the list. + * + * @param string|string[] $paths + * + * @return $this + */ + public function add($paths, bool $recursive = true) + { + $paths = (array) $paths; + + foreach ($paths as $path) { + if (! is_string($path)) { + throw new InvalidArgumentException('FileCollection paths must be strings.'); + } + + try { + // Test for a directory + self::resolveDirectory($path); + } catch (FileException $e) { + return $this->addFile($path); + } + + $this->addDirectory($path, $recursive); + } + + return $this; + } + + //-------------------------------------------------------------------- + // File Handling + //-------------------------------------------------------------------- + + /** + * Verifies and adds files to the list. + * + * @param string[] $files + * + * @return $this + */ + public function addFiles(array $files) + { + foreach ($files as $file) { + $this->addFile($file); + } + + return $this; + } + + /** + * Verifies and adds a single file to the file list. + * + * @return $this + */ + public function addFile(string $file) + { + $this->files[] = self::resolveFile($file); + + return $this; + } + + /** + * Removes files from the list. + * + * @param string[] $files + * + * @return $this + */ + public function removeFiles(array $files) + { + $this->files = array_diff($this->files, $files); + + return $this; + } + + /** + * Removes a single file from the list. + * + * @return $this + */ + public function removeFile(string $file) + { + return $this->removeFiles([$file]); + } + + //-------------------------------------------------------------------- + // Directory Handling + //-------------------------------------------------------------------- + + /** + * Verifies and adds files from each + * directory to the list. + * + * @param string[] $directories + * + * @return $this + */ + public function addDirectories(array $directories, bool $recursive = false) + { + foreach ($directories as $directory) { + $this->addDirectory($directory, $recursive); + } + + return $this; + } + + /** + * Verifies and adds all files from a directory. + * + * @return $this + */ + public function addDirectory(string $directory, bool $recursive = false) + { + $directory = self::resolveDirectory($directory); + + // Map the directory to depth 2 to so directories become arrays + foreach (directory_map($directory, 2, true) as $key => $path) { + if (is_string($path)) { + $this->addFile($directory . $path); + } elseif ($recursive && is_array($path)) { + $this->addDirectory($directory . $key, true); + } + } + + return $this; + } + + //-------------------------------------------------------------------- + // Filtering + //-------------------------------------------------------------------- + + /** + * Removes any files from the list that match the supplied pattern + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope The directory to limit the scope + * + * @return $this + */ + public function removePattern(string $pattern, ?string $scope = null) + { + if ($pattern === '') { + return $this; + } + + // Start with all files or those in scope + $files = $scope === null ? $this->files : self::filterFiles($this->files, $scope); + + // Remove any files that match the pattern + return $this->removeFiles(self::matchFiles($files, $pattern)); + } + + /** + * Keeps only the files from the list that match + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function retainPattern(string $pattern, ?string $scope = null) + { + if ($pattern === '') { + return $this; + } + + // Start with all files or those in scope + $files = $scope === null ? $this->files : self::filterFiles($this->files, $scope); + + // Matches the pattern within the scoped files and remove their inverse. + return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); + } + + //-------------------------------------------------------------------- + // Interface Methods + //-------------------------------------------------------------------- + + /** + * Returns the current number of files in the collection. + * Fulfills Countable. + */ + public function count(): int + { + return count($this->files); + } + + /** + * Yields as an Iterator for the current files. + * Fulfills IteratorAggregate. + * + * @throws FileNotFoundException + * + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->get() as $file) { + yield new File($file, true); + } + } +} diff --git a/system/Filters/CSRF.php b/system/Filters/CSRF.php index 9d17b5e6e241..02894491de8d 100644 --- a/system/Filters/CSRF.php +++ b/system/Filters/CSRF.php @@ -39,7 +39,7 @@ class CSRF implements FilterInterface * * @throws SecurityException * - * @return mixed + * @return mixed|void */ public function before(RequestInterface $request, $arguments = null) { @@ -65,7 +65,7 @@ public function before(RequestInterface $request, $arguments = null) * * @param array|null $arguments * - * @return mixed + * @return mixed|void */ public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index 9e3a0aa7913d..faa1df358cb4 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -107,12 +107,16 @@ public function __construct($config, RequestInterface $request, ResponseInterfac $this->setResponse($response); $this->modules = $modules ?? config('Modules'); + + if ($this->modules->shouldDiscover('filters')) { + $this->discoverFilters(); + } } /** * If discoverFilters is enabled in Config then system will try to * auto-discover custom filters files in Namespaces and allow access to - * the config object via the variable $customfilters as with the routes file + * the config object via the variable $filters as with the routes file * * Sample : * $filters->aliases['custom-auth'] = \Acme\Blob\Filters\BlobAuth::class; @@ -211,12 +215,10 @@ public function run(string $uri, string $position = 'before') * The resulting $this->filters is an array of only filters * that should be applied to this request. * - * We go ahead an process the entire tree because we'll need to + * We go ahead and process the entire tree because we'll need to * run through both a before and after and don't want to double * process the rows. * - * @param string $uri - * * @return Filters */ public function initialize(?string $uri = null) @@ -225,10 +227,6 @@ public function initialize(?string $uri = null) return $this; } - if ($this->modules->shouldDiscover('filters')) { - $this->discoverFilters(); - } - $this->processGlobals($uri); $this->processMethods(); $this->processFilters($uri); @@ -319,6 +317,8 @@ public function addFilter(string $class, ?string $alias = null, string $when = ' * are passed to the filter when executed. * * @return Filters + * + * @deprecated Use enableFilters(). This method will be private. */ public function enableFilter(string $name, string $when = 'before') { @@ -334,7 +334,9 @@ public function enableFilter(string $name, string $when = 'before') $this->arguments[$name] = $params; } - if (! array_key_exists($name, $this->config->aliases)) { + if (class_exists($name)) { + $this->config->aliases[$name] = $name; + } elseif (! array_key_exists($name, $this->config->aliases)) { throw FilterException::forNoAlias($name); } @@ -352,6 +354,24 @@ public function enableFilter(string $name, string $when = 'before') return $this; } + /** + * Ensures that specific filters is on and enabled for the current request. + * + * Filters can have "arguments". This is done by placing a colon immediately + * after the filter name, followed by a comma-separated list of arguments that + * are passed to the filter when executed. + * + * @return Filters + */ + public function enableFilters(array $names, string $when = 'before') + { + foreach ($names as $filter) { + $this->enableFilter($filter, $when); + } + + return $this; + } + /** * Returns the arguments for a specified key, or all. * @@ -421,7 +441,7 @@ protected function processMethods() } // Request method won't be set for CLI-based requests - $method = strtolower($_SERVER['REQUEST_METHOD'] ?? 'cli'); + $method = strtolower($this->request->getMethod()) ?? 'cli'; if (array_key_exists($method, $this->config->methods)) { $this->filters['before'] = array_merge($this->filters['before'], $this->config->methods[$method]); diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index b8db857fce54..fcc2a51389d2 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -15,8 +15,6 @@ use RuntimeException; /** - * Class CLIRequest - * * Represents a request from the command-line. Provides additional * tools to interact with that request since CLI requests are not * static like HTTP requests might be. @@ -172,17 +170,17 @@ protected function parseCommand() if ($optionValue) { $optionValue = false; } else { - $this->segments[] = filter_var($arg, FILTER_SANITIZE_STRING); + $this->segments[] = esc(strip_tags($arg)); } continue; } - $arg = filter_var(ltrim($arg, '-'), FILTER_SANITIZE_STRING); + $arg = esc(strip_tags(ltrim($arg, '-'))); $value = null; if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { - $value = filter_var($args[$i + 1], FILTER_SANITIZE_STRING); + $value = esc(strip_tags($args[$i + 1])); $optionValue = true; } diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 3be8214d42b4..fffe57258a54 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -13,13 +13,11 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; +use Config\CURLRequest as ConfigCURLRequest; use InvalidArgumentException; /** - * Class OutgoingRequest - * - * A lightweight HTTP client for sending synchronous HTTP requests - * via cURL. + * A lightweight HTTP client for sending synchronous HTTP requests via cURL. */ class CURLRequest extends Request { @@ -42,7 +40,14 @@ class CURLRequest extends Request * * @var array */ - protected $config = [ + protected $config; + + /** + * The default setting values + * + * @var array + */ + protected $defaultConfig = [ 'timeout' => 0.0, 'connect_timeout' => 150, 'debug' => false, @@ -72,6 +77,23 @@ class CURLRequest extends Request */ protected $delay = 0.0; + /** + * The default options from the constructor. Applied to all requests. + * + * @var array + */ + private $defaultOptions; + + /** + * Whether share options between requests or not. + * + * If true, all the options won't be reset between requests. + * It may cause an error request with unnecessary headers. + * + * @var bool + */ + private $shareOptions; + /** * Takes an array of options to set the following possible class properties: * @@ -84,17 +106,20 @@ class CURLRequest extends Request public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = []) { if (! function_exists('curl_version')) { - // we won't see this during travis-CI - // @codeCoverageIgnoreStart - throw HTTPException::forMissingCurl(); - // @codeCoverageIgnoreEnd + throw HTTPException::forMissingCurl(); // @codeCoverageIgnore } parent::__construct($config); - $this->response = $response; - $this->baseURI = $uri->useRawQueryString(); + $this->response = $response; + $this->baseURI = $uri->useRawQueryString(); + $this->defaultOptions = $options; + + /** @var ConfigCURLRequest|null $configCURLRequest */ + $configCURLRequest = config('CURLRequest'); + $this->shareOptions = $configCURLRequest->shareOptions ?? true; + $this->config = $this->defaultConfig; $this->parseOptions($options); } @@ -110,13 +135,33 @@ public function request($method, string $url, array $options = []): ResponseInte $url = $this->prepareURL($url); - $method = filter_var($method, FILTER_SANITIZE_STRING); + $method = esc(strip_tags($method)); $this->send($method, $url); + if ($this->shareOptions === false) { + $this->resetOptions(); + } + return $this->response; } + /** + * Reset all options to default. + */ + protected function resetOptions() + { + // Reset headers + $this->headers = []; + $this->headerMap = []; + + // Reset configs + $this->config = $this->defaultConfig; + + // Set the default options for next request + $this->parseOptions($this->defaultOptions); + } + /** * Convenience method for sending a GET request. */ @@ -350,27 +395,17 @@ public function send(string $method, string $url) } /** - * Takes all headers current part of this request and adds them - * to the cURL request. + * Adds $this->headers to the cURL request. */ protected function applyRequestHeaders(array $curlOptions = []): array { if (empty($this->headers)) { - $this->populateHeaders(); - // Otherwise, it will corrupt the request - $this->removeHeader('Host'); - $this->removeHeader('Accept-Encoding'); - } - - $headers = $this->headers(); - - if (empty($headers)) { return $curlOptions; } $set = []; - foreach (array_keys($headers) as $name) { + foreach (array_keys($this->headers) as $name) { $set[] = $name . ': ' . $this->getHeaderLine($name); } diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 4dec7ebc610f..128d6ee1c432 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -51,6 +51,8 @@ class IncomingRequest extends Request * Set automatically based on Config setting. * * @var bool + * + * @deprecated Not used */ protected $enableCSRF = false; @@ -535,6 +537,10 @@ public function getJsonVar(string $index, bool $assoc = false, ?int $filter = nu $data = dot_array_search($index, $this->getJSON(true)); + if ($data === null) { + return null; + } + if (! is_array($data)) { $filter = $filter ?? FILTER_DEFAULT; $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 50444a481acf..c0bb005099ab 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -279,7 +279,7 @@ public function fetchGlobal(string $method, $index = null, ?int $filter = null, $filter !== FILTER_DEFAULT || ( (is_numeric($flags) && $flags !== 0) - || is_array($flags) && count($flags) > 0 + || is_array($flags) && $flags !== [] ) ) ) { diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 70f31bd35f72..4efc6e804d3f 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -79,15 +79,15 @@ class Response extends Message implements MessageInterface, ResponseInterface 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', - 413 => 'Request Entity Too Large', - 414 => 'Request-URI Too Long', + 413 => 'Content Too Large', // https://www.iana.org/assignments/http-status-codes/http-status-codes.xml + 414 => 'URI Too Long', // https://www.iana.org/assignments/http-status-codes/http-status-codes.xml 415 => 'Unsupported Media Type', 416 => 'Requested Range Not Satisfiable', 417 => 'Expectation Failed', 418 => "I'm a teapot", // April's Fools joke; http://www.ietf.org/rfc/rfc2324.txt // 419 (Authentication Timeout) is a non-standard status code with unknown origin 421 => 'Misdirected Request', // http://www.iana.org/go/rfc7540 Section 9.1.2 - 422 => 'Unprocessable Entity', // http://www.iana.org/go/rfc4918 + 422 => 'Unprocessable Content', // https://www.iana.org/assignments/http-status-codes/http-status-codes.xml 423 => 'Locked', // http://www.iana.org/go/rfc4918 424 => 'Failed Dependency', // http://www.iana.org/go/rfc4918 425 => 'Too Early', // https://datatracker.ietf.org/doc/draft-ietf-httpbis-replay/ diff --git a/system/Helpers/number_helper.php b/system/Helpers/number_helper.php index 6e1391556c49..b3d7c80c6006 100644 --- a/system/Helpers/number_helper.php +++ b/system/Helpers/number_helper.php @@ -26,7 +26,7 @@ function number_to_size($num, int $precision = 1, ?string $locale = null) { // Strip any formatting & ensure numeric input try { - $num = 0 + str_replace(',', '', $num); // @phpstan-ignore-line + $num = 0 + str_replace(',', '', $num); } catch (ErrorException $ee) { return false; } @@ -182,79 +182,36 @@ function format_number(float $num, int $precision = 1, ?string $locale = null, a */ function number_to_roman(string $num): ?string { + static $map = [ + 'M' => 1000, + 'CM' => 900, + 'D' => 500, + 'CD' => 400, + 'C' => 100, + 'XC' => 90, + 'L' => 50, + 'XL' => 40, + 'X' => 10, + 'IX' => 9, + 'V' => 5, + 'IV' => 4, + 'I' => 1, + ]; + $num = (int) $num; + if ($num < 1 || $num > 3999) { return null; } - $_number_to_roman = static function ($num, $th) use (&$_number_to_roman) { - $return = ''; - $key1 = null; - $key2 = null; - - switch ($th) { - case 1: - $key1 = 'I'; - $key2 = 'V'; - $keyF = 'X'; - break; - - case 2: - $key1 = 'X'; - $key2 = 'L'; - $keyF = 'C'; - break; - - case 3: - $key1 = 'C'; - $key2 = 'D'; - $keyF = 'M'; - break; - - case 4: - $key1 = 'M'; - break; - } - $n = $num % 10; + $result = ''; - switch ($n) { - case 1: - case 2: - case 3: - $return = str_repeat($key1, $n); - break; - - case 4: - $return = $key1 . $key2; - break; - - case 5: - $return = $key2; - break; - - case 6: - case 7: - case 8: - $return = $key2 . str_repeat($key1, $n - 5); - break; - - case 9: - $return = $key1 . $keyF; // @phpstan-ignore-line - break; - } - - switch ($num) { - case 10: - $return = $keyF; // @phpstan-ignore-line - break; - } - if ($num > 10) { - $return = $_number_to_roman($num / 10, ++$th) . $return; - } - - return $return; - }; + foreach ($map as $roman => $arabic) { + $repeat = (int) floor($num / $arabic); + $result .= str_repeat($roman, $repeat); + $num %= $arabic; + } - return $_number_to_roman($num, 1); + return $result; } } diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index e20054ab6893..fe6b06794ccd 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -9,8 +9,10 @@ * the LICENSE file that was distributed with this source code. */ +use CodeIgniter\Exceptions\TestException; use CodeIgniter\Model; use CodeIgniter\Test\Fabricator; +use Config\Services; // CodeIgniter Test Helpers @@ -20,16 +22,14 @@ * * @param Model|object|string $model Instance or name of the model * @param array|null $overrides Overriding data to pass to Fabricator::setOverrides() - * @param mixed $persist + * @param bool $persist * * @return array|object */ function fake($model, ?array $overrides = null, $persist = true) { - // Get a model-appropriate Fabricator instance $fabricator = new Fabricator($model); - // Set overriding data, if necessary if ($overrides) { $fabricator->setOverrides($overrides); } @@ -41,3 +41,28 @@ function fake($model, ?array $overrides = null, $persist = true) return $fabricator->make(); } } + +if (! function_exists('mock')) { + /** + * Used within our test suite to mock certain system tools. + * + * @param string $className Fully qualified class name + */ + function mock(string $className) + { + $mockClass = $className::$mockClass; + $mockService = $className::$mockServiceName ?? ''; + + if (empty($mockClass) || ! class_exists($mockClass)) { + throw TestException::forInvalidMockClass($mockClass); + } + + $mock = new $mockClass(); + + if (! empty($mockService)) { + Services::injectMock($mockService, $mock); + } + + return $mock; + } +} diff --git a/system/Language/en/Files.php b/system/Language/en/Files.php index 924e98ea354a..03fa776e4822 100644 --- a/system/Language/en/Files.php +++ b/system/Language/en/Files.php @@ -11,6 +11,8 @@ // Files language settings return [ - 'fileNotFound' => 'File not found: {0}', - 'cannotMove' => 'Could not move file {0} to {1} ({2}).', + 'fileNotFound' => 'File not found: {0}', + 'cannotMove' => 'Could not move file {0} to {1} ({2}).', + 'expectedDirectory' => '{0} expects a valid directory.', + 'expectedFile' => '{0} expects a valid file.', ]; diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php new file mode 100644 index 000000000000..f335b98423f7 --- /dev/null +++ b/system/Language/en/Publisher.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Publisher language settings +return [ + 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', + 'destinationNotAllowed' => 'Destination is not on the allowed list of Publisher directories: {0}', + 'fileNotAllowed' => '{0} fails the following restriction for {1}: {2}', + + // Publish Command + 'publishMissing' => 'No Publisher classes detected in {0} across all namespaces.', + 'publishSuccess' => '{0} published {1} file(s) to {2}.', + 'publishFailure' => '{0} failed to publish to {1}!', +]; diff --git a/system/Language/en/Test.php b/system/Language/en/Test.php new file mode 100644 index 000000000000..a3f5d7ef6f02 --- /dev/null +++ b/system/Language/en/Test.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Testing language settings +return [ + 'invalidMockClass' => '{0} is not a valid Mock class', +]; diff --git a/system/Model.php b/system/Model.php index 3617f83f69cb..c73e5c716778 100644 --- a/system/Model.php +++ b/system/Model.php @@ -251,7 +251,7 @@ protected function doInsert(array $data) * This methods works only with dbCalls * * @param array|null $set An associative array of insert values - * @param bool|null $escape Whether to escape values and identifiers + * @param bool|null $escape Whether to escape values * @param int $batchSize The size of the batch to run * @param bool $testing True means only number of records is returned, false will execute the query * @@ -557,13 +557,13 @@ public function builder(?string $table = null) * data here. This allows it to be used with any of the other * builder methods and still get validated data, like replace. * - * @param array|string $key Field name, or an array of field/value pairs - * @param string|null $value Field value, if $key is a single field - * @param bool|null $escape Whether to escape values and identifiers + * @param mixed $key Field name, or an array of field/value pairs + * @param mixed $value Field value, if $key is a single field + * @param bool|null $escape Whether to escape values * * @return $this */ - public function set($key, ?string $value = '', ?bool $escape = null) + public function set($key, $value = '', ?bool $escape = null) { $data = is_array($key) ? $key : [$key => $value]; @@ -710,24 +710,19 @@ public function __isset(string $name): bool * Provides direct access to method in the builder (if available) * and the database connection. * - * @return $this|null + * @return mixed */ public function __call(string $name, array $params) { - $result = parent::__call($name, $params); + $builder = $this->builder(); + $result = null; - if ($result === null && method_exists($builder = $this->builder(), $name)) { + if (method_exists($this->db, $name)) { + $result = $this->db->{$name}(...$params); + } elseif (method_exists($builder, $name)) { $result = $builder->{$name}(...$params); - } - - if (empty($result)) { - if (! method_exists($this->builder(), $name)) { - $className = static::class; - - throw new BadMethodCallException('Call to undefined method ' . $className . '::' . $name); - } - - return $result; + } else { + throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name); } if ($result instanceof BaseBuilder) { diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php new file mode 100644 index 000000000000..535d3a757d15 --- /dev/null +++ b/system/Publisher/Exceptions/PublisherException.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * Publisher Exception Class + * + * Handles exceptions related to actions taken by a Publisher. + */ +class PublisherException extends FrameworkException +{ + /** + * Throws when a file should be overwritten yet cannot. + * + * @param string $from The source file + * @param string $to The destination file + */ + public static function forCollision(string $from, string $to) + { + return new static(lang('Publisher.collision', [filetype($to), $from, $to])); + } + + /** + * Throws when given a destination that is not in the list of allowed directories. + */ + public static function forDestinationNotAllowed(string $destination) + { + return new static(lang('Publisher.destinationNotAllowed', [$destination])); + } + + /** + * Throws when a file fails to match the allowed pattern for its destination. + */ + public static function forFileNotAllowed(string $file, string $directory, string $pattern) + { + return new static(lang('Publisher.fileNotAllowed', [$file, $directory, $pattern])); + } +} diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php new file mode 100644 index 000000000000..dac7aa898f1a --- /dev/null +++ b/system/Publisher/Publisher.php @@ -0,0 +1,440 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Files\FileCollection; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Publisher\Exceptions\PublisherException; +use RuntimeException; +use Throwable; + +/** + * Publishers read in file paths from a variety of sources and copy + * the files out to different destinations. This class acts both as + * a base for individual publication directives as well as the mode + * of discovery for said instances. In this class a "file" is a full + * path to a verified file while a "path" is relative to its source + * or destination and may indicate either a file or directory of + * unconfirmed existence. + * + * Class failures throw the PublisherException, but some underlying + * methods may percolate different exceptions, like FileException, + * FileNotFoundException or InvalidArgumentException. + * + * Write operations will catch all errors in the file-specific + * $errors property to minimize impact of partial batch operations. + */ +class Publisher extends FileCollection +{ + /** + * Array of discovered Publishers. + * + * @var array + */ + private static $discovered = []; + + /** + * Directory to use for methods that need temporary storage. + * Created on-the-fly as needed. + * + * @var string|null + */ + private $scratch; + + /** + * Exceptions for specific files from the last write operation. + * + * @var array + */ + private $errors = []; + + /** + * List of file published curing the last write operation. + * + * @var string[] + */ + private $published = []; + + /** + * List of allowed directories and their allowed files regex. + * Restrictions are intentionally private to prevent overriding. + * + * @var array + */ + private $restrictions; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = ROOTPATH; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = FCPATH; + + //-------------------------------------------------------------------- + // Support Methods + //-------------------------------------------------------------------- + + /** + * Discovers and returns all Publishers in the specified namespace directory. + * + * @return self[] + */ + final public static function discover(string $directory = 'Publishers'): array + { + if (isset(self::$discovered[$directory])) { + return self::$discovered[$directory]; + } + + self::$discovered[$directory] = []; + + /** @var FileLocator $locator */ + $locator = service('locator'); + + if ([] === $files = $locator->listFiles($directory)) { + return []; + } + + // Loop over each file checking to see if it is a Publisher + foreach (array_unique($files) as $file) { + $className = $locator->findQualifiedNameFromPath($file); + + if (is_string($className) && class_exists($className) && is_a($className, self::class, true)) { + self::$discovered[$directory][] = new $className(); + } + } + + sort(self::$discovered[$directory]); + + return self::$discovered[$directory]; + } + + /** + * Removes a directory and all its files and subdirectories. + */ + private static function wipeDirectory(string $directory): void + { + if (is_dir($directory)) { + // Try a few times in case of lingering locks + $attempts = 10; + + while ((bool) $attempts && ! delete_files($directory, true, false, true)) { + // @codeCoverageIgnoreStart + $attempts--; + usleep(100000); // .1s + // @codeCoverageIgnoreEnd + } + + @rmdir($directory); + } + } + + //-------------------------------------------------------------------- + // Class Core + //-------------------------------------------------------------------- + + /** + * Loads the helper and verifies the source and destination directories. + */ + public function __construct(?string $source = null, ?string $destination = null) + { + helper(['filesystem']); + + $this->source = self::resolveDirectory($source ?? $this->source); + $this->destination = self::resolveDirectory($destination ?? $this->destination); + + // Restrictions are intentionally not injected to prevent overriding + $this->restrictions = config('Publisher')->restrictions; + + // Make sure the destination is allowed + foreach (array_keys($this->restrictions) as $directory) { + if (strpos($this->destination, $directory) === 0) { + return; + } + } + + throw PublisherException::forDestinationNotAllowed($this->destination); + } + + /** + * Cleans up any temporary files in the scratch space. + */ + public function __destruct() + { + if (isset($this->scratch)) { + self::wipeDirectory($this->scratch); + + $this->scratch = null; + } + } + + /** + * Reads files from the sources and copies them out to their destinations. + * This method should be reimplemented by child classes intended for + * discovery. + * + * @throws RuntimeException + */ + public function publish(): bool + { + // Safeguard against accidental misuse + if ($this->source === ROOTPATH && $this->destination === FCPATH) { + throw new RuntimeException('Child classes of Publisher should provide their own publish method or a source and destination.'); + } + + return $this->addPath('/')->merge(true); + } + + //-------------------------------------------------------------------- + // Property Accessors + //-------------------------------------------------------------------- + + /** + * Returns the source directory. + */ + final public function getSource(): string + { + return $this->source; + } + + /** + * Returns the destination directory. + */ + final public function getDestination(): string + { + return $this->destination; + } + + /** + * Returns the temporary workspace, creating it if necessary. + */ + final public function getScratch(): string + { + if ($this->scratch === null) { + $this->scratch = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)) . DIRECTORY_SEPARATOR; + mkdir($this->scratch, 0700); + $this->scratch = realpath($this->scratch) ? realpath($this->scratch) . DIRECTORY_SEPARATOR + : $this->scratch; + } + + return $this->scratch; + } + + /** + * Returns errors from the last write operation if any. + * + * @return array + */ + final public function getErrors(): array + { + return $this->errors; + } + + /** + * Returns the files published by the last write operation. + * + * @return string[] + */ + final public function getPublished(): array + { + return $this->published; + } + + //-------------------------------------------------------------------- + // Additional Handlers + //-------------------------------------------------------------------- + + /** + * Verifies and adds paths to the list. + * + * @param string[] $paths + * + * @return $this + */ + final public function addPaths(array $paths, bool $recursive = true) + { + foreach ($paths as $path) { + $this->addPath($path, $recursive); + } + + return $this; + } + + /** + * Adds a single path to the file list. + * + * @return $this + */ + final public function addPath(string $path, bool $recursive = true) + { + $this->add($this->source . $path, $recursive); + + return $this; + } + + /** + * Downloads and stages files from an array of URIs. + * + * @param string[] $uris + * + * @return $this + */ + final public function addUris(array $uris) + { + foreach ($uris as $uri) { + $this->addUri($uri); + } + + return $this; + } + + /** + * Downloads a file from the URI, and adds it to the file list. + * + * @param string $uri Because HTTP\URI is stringable it will still be accepted + * + * @return $this + */ + final public function addUri(string $uri) + { + // Figure out a good filename (using URI strips queries and fragments) + $file = $this->getScratch() . basename((new URI($uri))->getPath()); + + // Get the content and write it to the scratch space + write_file($file, service('curlrequest')->get($uri)->getBody()); + + return $this->addFile($file); + } + + //-------------------------------------------------------------------- + // Write Methods + //-------------------------------------------------------------------- + + /** + * Removes the destination and all its files and folders. + * + * @return $this + */ + final public function wipe() + { + self::wipeDirectory($this->destination); + + return $this; + } + + /** + * Copies all files into the destination, does not create directory structure. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return bool Whether all files were copied successfully + */ + final public function copy(bool $replace = true): bool + { + $this->errors = $this->published = []; + + foreach ($this->get() as $file) { + $to = $this->destination . basename($file); + + try { + $this->safeCopyFile($file, $to, $replace); + $this->published[] = $to; + } catch (Throwable $e) { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } + + /** + * Merges all files into the destination. + * Creates a mirrored directory structure only for files from source. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return bool Whether all files were copied successfully + */ + final public function merge(bool $replace = true): bool + { + $this->errors = $this->published = []; + + // Get the files from source for special handling + $sourced = self::filterFiles($this->get(), $this->source); + + // Handle everything else with a flat copy + $this->files = array_diff($this->files, $sourced); + $this->copy($replace); + + // Copy each sourced file to its relative destination + foreach ($sourced as $file) { + // Resolve the destination path + $to = $this->destination . substr($file, strlen($this->source)); + + try { + $this->safeCopyFile($file, $to, $replace); + $this->published[] = $to; + } catch (Throwable $e) { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } + + /** + * Copies a file with directory creation and identical file awareness. + * Intentionally allows errors. + * + * @throws PublisherException For collisions and restriction violations + */ + private function safeCopyFile(string $from, string $to, bool $replace): void + { + // Verify this is an allowed file for its destination + foreach ($this->restrictions as $directory => $pattern) { + if (strpos($to, $directory) === 0 && self::matchFiles([$to], $pattern) === []) { + throw PublisherException::forFileNotAllowed($from, $directory, $pattern); + } + } + + // Check for an existing file + if (file_exists($to)) { + // If not replacing or if files are identical then consider successful + if (! $replace || same_file($from, $to)) { + return; + } + + // If it is a directory then do not try to remove it + if (is_dir($to)) { + throw PublisherException::forCollision($from, $to); + } + + // Try to remove anything else + unlink($to); + } + + // Make sure the directory exists + if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) { + mkdir($directory, 0775, true); + } + + // Allow copy() to throw errors + copy($from, $to); + } +} diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index e975a1ac86b9..15a2d3d79e3e 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -19,8 +19,6 @@ use InvalidArgumentException; /** - * Class RouteCollection - * * @todo Implement nested resource routing (See CakePHP) */ class RouteCollection implements RouteCollectionInterface @@ -512,7 +510,7 @@ public function map(array $routes = [], ?array $options = null): RouteCollection * Example: * $routes->add('news', 'Posts::index'); * - * @param array|string $to + * @param array|Closure|string $to */ public function add(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -663,10 +661,11 @@ public function resource(string $name, ?array $options = null): RouteCollectionI // resources are sent to, we need to have a new name // to store the values in. $newName = implode('\\', array_map('ucfirst', explode('/', $name))); + // If a new controller is specified, then we replace the // $name value with the name of the new controller. if (isset($options['controller'])) { - $newName = ucfirst(filter_var($options['controller'], FILTER_SANITIZE_STRING)); + $newName = ucfirst(esc(strip_tags($options['controller']))); } // In order to allow customization of allowed id values @@ -756,10 +755,11 @@ public function presenter(string $name, ?array $options = null): RouteCollection // resources are sent to, we need to have a new name // to store the values in. $newName = implode('\\', array_map('ucfirst', explode('/', $name))); + // If a new controller is specified, then we replace the // $name value with the name of the new controller. if (isset($options['controller'])) { - $newName = ucfirst(filter_var($options['controller'], FILTER_SANITIZE_STRING)); + $newName = ucfirst(esc(strip_tags($options['controller']))); } // In order to allow customization of allowed id values @@ -821,7 +821,7 @@ public function presenter(string $name, ?array $options = null): RouteCollection * Example: * $route->match( ['get', 'post'], 'users/(:num)', 'users/$1); * - * @param array|string $to + * @param array|Closure|string $to */ public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface { @@ -841,7 +841,7 @@ public function match(array $verbs = [], string $from = '', $to = '', ?array $op /** * Specifies a route that is only available to GET requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function get(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -853,7 +853,7 @@ public function get(string $from, $to, ?array $options = null): RouteCollectionI /** * Specifies a route that is only available to POST requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function post(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -865,7 +865,7 @@ public function post(string $from, $to, ?array $options = null): RouteCollection /** * Specifies a route that is only available to PUT requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function put(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -877,7 +877,7 @@ public function put(string $from, $to, ?array $options = null): RouteCollectionI /** * Specifies a route that is only available to DELETE requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -889,7 +889,7 @@ public function delete(string $from, $to, ?array $options = null): RouteCollecti /** * Specifies a route that is only available to HEAD requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function head(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -901,7 +901,7 @@ public function head(string $from, $to, ?array $options = null): RouteCollection /** * Specifies a route that is only available to PATCH requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -913,7 +913,7 @@ public function patch(string $from, $to, ?array $options = null): RouteCollectio /** * Specifies a route that is only available to OPTIONS requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function options(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -925,7 +925,7 @@ public function options(string $from, $to, ?array $options = null): RouteCollect /** * Specifies a route that is only available to command-line requests. * - * @param array|string $to + * @param array|Closure|string $to */ public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1040,6 +1040,8 @@ public function isFiltered(string $search, ?string $verb = null): bool * 'role:admin,manager' * * has a filter of "role", with parameters of ['admin', 'manager']. + * + * @deprecated Use getFiltersForRoute() */ public function getFilterForRoute(string $search, ?string $verb = null): string { @@ -1048,6 +1050,27 @@ public function getFilterForRoute(string $search, ?string $verb = null): string return $options[$search]['filter'] ?? ''; } + /** + * Returns the filters that should be applied for a single route, along + * with any parameters it might have. Parameters are found by splitting + * the parameter name on a colon to separate the filter name from the parameter list, + * and the splitting the result on commas. So: + * + * 'role:admin,manager' + * + * has a filter of "role", with parameters of ['admin', 'manager']. + */ + public function getFiltersForRoute(string $search, ?string $verb = null): array + { + $options = $this->loadRoutesOptions($verb); + + if (is_string($options[$search]['filter'])) { + return [$options[$search]['filter']]; + } + + return $options[$search]['filter'] ?? []; + } + /** * Given a * @@ -1083,7 +1106,7 @@ protected function fillRouteParams(string $from, ?array $params = null): string * the request method(s) that this route will work for. They can be separated * by a pipe character "|" if there is more than one. * - * @param array|string $to + * @param array|Closure|string $to */ protected function create(string $verb, string $from, $to, ?array $options = null) { diff --git a/system/Router/RouteCollectionInterface.php b/system/Router/RouteCollectionInterface.php index da25077da0fb..a55b5d80a0b9 100644 --- a/system/Router/RouteCollectionInterface.php +++ b/system/Router/RouteCollectionInterface.php @@ -28,8 +28,8 @@ interface RouteCollectionInterface /** * Adds a single route to the collection. * - * @param array|string $to - * @param array $options + * @param array|Closure|string $to + * @param array $options * * @return mixed */ diff --git a/system/Router/Router.php b/system/Router/Router.php index a5ccef2c2e3b..621c54a5e9dd 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -99,9 +99,19 @@ class Router implements RouterInterface * if the matched route should be filtered. * * @var string|null + * + * @deprecated Use $filtersInfo */ protected $filterInfo; + /** + * The filter info from Route Collection + * if the matched route should be filtered. + * + * @var string[] + */ + protected $filtersInfo = []; + /** * Stores a reference to the RouteCollection object. * @@ -144,7 +154,13 @@ public function handle(?string $uri = null) if ($this->checkRoutes($uri)) { if ($this->collection->isFiltered($this->matchedRoute[0])) { - $this->filterInfo = $this->collection->getFilterForRoute($this->matchedRoute[0]); + $multipleFiltersEnabled = config('Feature')->multipleFilters ?? false; + if ($multipleFiltersEnabled) { + $this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]); + } else { + // for backward compatibility + $this->filterInfo = $this->collection->getFilterForRoute($this->matchedRoute[0]); + } } return $this->controller; @@ -166,12 +182,24 @@ public function handle(?string $uri = null) * Returns the filter info for the matched route, if any. * * @return string + * + * @deprecated Use getFilters() */ public function getFilter() { return $this->filterInfo; } + /** + * Returns the filter info for the matched route, if any. + * + * @return string[] + */ + public function getFilters(): array + { + return $this->filtersInfo; + } + /** * Returns the name of the matched controller. * @@ -330,7 +358,7 @@ protected function checkRoutes(string $uri): bool $uri = $uri === '/' ? $uri - : ltrim($uri, '/ '); + : trim($uri, '/ '); // Loop through the route array looking for wildcards foreach ($routes as $key => $val) { diff --git a/system/Security/Security.php b/system/Security/Security.php index aa6cf6edabad..d9a44c4c9e6a 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -14,9 +14,11 @@ use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Session\Session; use Config\App; use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; +use Config\Services; /** * Class Security @@ -26,10 +28,22 @@ */ class Security implements SecurityInterface { + public const CSRF_PROTECTION_COOKIE = 'cookie'; + public const CSRF_PROTECTION_SESSION = 'session'; + + /** + * CSRF Protection Method + * + * Protection Method for Cross Site Request Forgery protection. + * + * @var string 'cookie' or 'session' + */ + protected $csrfProtection = self::CSRF_PROTECTION_COOKIE; + /** * CSRF Hash * - * Random hash for Cross Site Request Forgery protection cookie + * Random hash for Cross Site Request Forgery protection. * * @var string|null */ @@ -38,7 +52,7 @@ class Security implements SecurityInterface /** * CSRF Token Name * - * Token name for Cross Site Request Forgery protection cookie. + * Token name for Cross Site Request Forgery protection. * * @var string */ @@ -47,7 +61,7 @@ class Security implements SecurityInterface /** * CSRF Header Name * - * Token name for Cross Site Request Forgery protection cookie. + * Header name for Cross Site Request Forgery protection. * * @var string */ @@ -63,7 +77,7 @@ class Security implements SecurityInterface /** * CSRF Cookie Name * - * Cookie name for Cross Site Request Forgery protection cookie. + * Cookie name for Cross Site Request Forgery protection. * * @var string */ @@ -77,8 +91,6 @@ class Security implements SecurityInterface * Defaults to two hours (in seconds). * * @var int - * - * @deprecated */ protected $expires = 7200; @@ -117,6 +129,25 @@ class Security implements SecurityInterface */ protected $samesite = Cookie::SAMESITE_LAX; + /** + * @var RequestInterface + */ + private $request; + + /** + * CSRF Cookie Name without Prefix + * + * @var string + */ + private $rawCookieName; + + /** + * Session instance. + * + * @var Session + */ + private $session; + /** * Constructor. * @@ -125,27 +156,62 @@ class Security implements SecurityInterface */ public function __construct(App $config) { - /** @var SecurityConfig $security */ + /** @var SecurityConfig|null $security */ $security = config('Security'); // Store CSRF-related configurations - $this->tokenName = $security->tokenName ?? $config->CSRFTokenName ?? $this->tokenName; - $this->headerName = $security->headerName ?? $config->CSRFHeaderName ?? $this->headerName; - $this->regenerate = $security->regenerate ?? $config->CSRFRegenerate ?? $this->regenerate; - $rawCookieName = $security->cookieName ?? $config->CSRFCookieName ?? $this->cookieName; + if ($security instanceof SecurityConfig) { + $this->csrfProtection = $security->csrfProtection ?? $this->csrfProtection; + $this->tokenName = $security->tokenName ?? $this->tokenName; + $this->headerName = $security->headerName ?? $this->headerName; + $this->regenerate = $security->regenerate ?? $this->regenerate; + $this->rawCookieName = $security->cookieName ?? $this->rawCookieName; + $this->expires = $security->expires ?? $this->expires; + } else { + // `Config/Security.php` is absence + $this->tokenName = $config->CSRFTokenName ?? $this->tokenName; + $this->headerName = $config->CSRFHeaderName ?? $this->headerName; + $this->regenerate = $config->CSRFRegenerate ?? $this->regenerate; + $this->rawCookieName = $config->CSRFCookieName ?? $this->rawCookieName; + $this->expires = $config->CSRFExpire ?? $this->expires; + } - /** @var CookieConfig $cookie */ - $cookie = config('Cookie'); + if ($this->isCSRFCookie()) { + $this->configureCookie($config); + } else { + // Session based CSRF protection + $this->configureSession(); + } - $cookiePrefix = $cookie->prefix ?? $config->cookiePrefix; - $this->cookieName = $cookiePrefix . $rawCookieName; + $this->request = Services::request(); - $expires = $security->expires ?? $config->CSRFExpire ?? 7200; + $this->generateHash(); + } - Cookie::setDefaults($cookie); - $this->cookie = new Cookie($rawCookieName, $this->generateHash(), [ - 'expires' => $expires === 0 ? 0 : time() + $expires, - ]); + private function isCSRFCookie(): bool + { + return $this->csrfProtection === self::CSRF_PROTECTION_COOKIE; + } + + private function configureSession(): void + { + $this->session = Services::session(); + } + + private function configureCookie(App $config): void + { + /** @var CookieConfig|null $cookie */ + $cookie = config('Cookie'); + + if ($cookie instanceof CookieConfig) { + $cookiePrefix = $cookie->prefix; + $this->cookieName = $cookiePrefix . $this->rawCookieName; + Cookie::setDefaults($cookie); + } else { + // `Config/Cookie.php` is absence + $cookiePrefix = $config->cookiePrefix; + $this->cookieName = $cookiePrefix . $this->rawCookieName; + } } /** @@ -193,35 +259,26 @@ public function getCSRFTokenName(): string * * @throws SecurityException * - * @return $this|false + * @return $this */ public function verify(RequestInterface $request) { - // If it's not a POST request we will set the CSRF cookie. - if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST') { - return $this->sendCookie($request); - } - - // Does the token exist in POST, HEADER or optionally php:://input - json data. - if ($request->hasHeader($this->headerName) && ! empty($request->getHeader($this->headerName)->getValue())) { - $tokenName = $request->getHeader($this->headerName)->getValue(); - } else { - $json = json_decode($request->getBody()); - - if (! empty($request->getBody()) && ! empty($json) && json_last_error() === JSON_ERROR_NONE) { - $tokenName = $json->{$this->tokenName} ?? null; - } else { - $tokenName = null; - } + // Protects POST, PUT, DELETE, PATCH + $method = strtoupper($request->getMethod()); + $methodsToProtect = ['POST', 'PUT', 'DELETE', 'PATCH']; + if (! in_array($method, $methodsToProtect, true)) { + return $this; } - $token = $_POST[$this->tokenName] ?? $tokenName; + $token = $this->getPostedToken($request); - // Does the tokens exist in both the POST/POSTed JSON and COOKIE arrays and match? - if (! isset($token, $_COOKIE[$this->cookieName]) || ! hash_equals($token, $_COOKIE[$this->cookieName])) { + // Do the tokens match? + if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) { throw SecurityException::forDisallowedAction(); } + $json = json_decode($request->getBody()); + if (isset($_POST[$this->tokenName])) { // We kill this since we're done and we don't want to pollute the POST array. unset($_POST[$this->tokenName]); @@ -234,17 +291,39 @@ public function verify(RequestInterface $request) if ($this->regenerate) { $this->hash = null; - unset($_COOKIE[$this->cookieName]); + if ($this->isCSRFCookie()) { + unset($_COOKIE[$this->cookieName]); + } else { + // Session based CSRF protection + $this->session->remove($this->tokenName); + } } - $this->cookie = $this->cookie->withValue($this->generateHash()); - $this->sendCookie($request); + $this->generateHash(); log_message('info', 'CSRF token verified.'); return $this; } + private function getPostedToken(RequestInterface $request): ?string + { + // Does the token exist in POST, HEADER or optionally php:://input - json data. + if ($request->hasHeader($this->headerName) && ! empty($request->header($this->headerName)->getValue())) { + $tokenName = $request->header($this->headerName)->getValue(); + } else { + $json = json_decode($request->getBody()); + + if (! empty($request->getBody()) && ! empty($json) && json_last_error() === JSON_ERROR_NONE) { + $tokenName = $json->{$this->tokenName} ?? null; + } else { + $tokenName = null; + } + } + + return $request->getPost($this->tokenName) ?? $tokenName; + } + /** * Returns the CSRF Hash. */ @@ -373,19 +452,47 @@ protected function generateHash(): string // We don't necessarily want to regenerate it with // each page load since a page could contain embedded // sub-pages causing this feature to fail - if (isset($_COOKIE[$this->cookieName]) - && is_string($_COOKIE[$this->cookieName]) - && preg_match('#^[0-9a-f]{32}$#iS', $_COOKIE[$this->cookieName]) === 1 - ) { - return $this->hash = $_COOKIE[$this->cookieName]; + if ($this->isCSRFCookie()) { + if ($this->isHashInCookie()) { + return $this->hash = $_COOKIE[$this->cookieName]; + } + } elseif ($this->session->has($this->tokenName)) { + // Session based CSRF protection + return $this->hash = $this->session->get($this->tokenName); } $this->hash = bin2hex(random_bytes(16)); + + if ($this->isCSRFCookie()) { + $this->saveHashInCookie(); + } else { + // Session based CSRF protection + $this->saveHashInSession(); + } } return $this->hash; } + private function isHashInCookie(): bool + { + return isset($_COOKIE[$this->cookieName]) + && is_string($_COOKIE[$this->cookieName]) + && preg_match('#^[0-9a-f]{32}$#iS', $_COOKIE[$this->cookieName]) === 1; + } + + private function saveHashInCookie(): void + { + $this->cookie = new Cookie( + $this->rawCookieName, + $this->hash, + [ + 'expires' => $this->expires === 0 ? 0 : time() + $this->expires, + ] + ); + $this->sendCookie($this->request); + } + /** * CSRF Send Cookie * @@ -413,4 +520,9 @@ protected function doSendCookie(): void { cookies([$this->cookie], false)->dispatch(); } + + private function saveHashInSession(): void + { + $this->session->set($this->tokenName, $this->hash); + } } diff --git a/system/Session/Handlers/BaseHandler.php b/system/Session/Handlers/BaseHandler.php index 984b0fe7c3f6..f6fa57b23b68 100644 --- a/system/Session/Handlers/BaseHandler.php +++ b/system/Session/Handlers/BaseHandler.php @@ -121,11 +121,7 @@ protected function destroyCookie(): bool return setcookie( $this->cookieName, '', - 1, - $this->cookiePath, - $this->cookieDomain, - $this->cookieSecure, - true + ['expires' => 1, 'path' => $this->cookiePath, 'domain' => $this->cookieDomain, 'secure' => $this->cookieSecure, 'httponly' => true] ); } diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index 18da544d2873..f0164d2dfc3c 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -172,11 +172,10 @@ public function write($id, $data): bool $insertData = [ 'id' => $id, 'ip_address' => $this->ipAddress, - 'timestamp' => 'now()', 'data' => $this->platform === 'postgre' ? '\x' . bin2hex($data) : $data, ]; - if (! $this->db->table($this->table)->insert($insertData)) { + if (! $this->db->table($this->table)->set('timestamp', 'now()', false)->insert($insertData)) { return $this->fail(); } @@ -192,13 +191,13 @@ public function write($id, $data): bool $builder = $builder->where('ip_address', $this->ipAddress); } - $updateData = ['timestamp' => 'now()']; + $updateData = []; if ($this->fingerprint !== md5($data)) { $updateData['data'] = ($this->platform === 'postgre') ? '\x' . bin2hex($data) : $data; } - if (! $builder->update($updateData)) { + if (! $builder->set('timestamp', 'now()', false)->update($updateData)) { return $this->fail(); } @@ -257,7 +256,7 @@ public function gc($max_lifetime) $separator = $this->platform === 'postgre' ? '\'' : ' '; $interval = implode($separator, ['', "{$max_lifetime} second", '']); - return $this->db->table($this->table)->delete("timestamp < now() - INTERVAL {$interval}") ? 1 : $this->fail(); + return $this->db->table($this->table)->where('timestamp <', "now() - INTERVAL {$interval}", false)->delete() ? 1 : $this->fail(); } /** diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 8ed7b8c052b8..ce6e7bde5180 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -137,7 +137,7 @@ public function read($id) { if (isset($this->redis) && $this->lockSession($id)) { if (! isset($this->sessionID)) { - $this->sessionID = ${$id}; + $this->sessionID = $id; } $data = $this->redis->get($this->keyPrefix . $id); diff --git a/system/Session/Session.php b/system/Session/Session.php index 5fc1bda6005e..ebfb6a94d082 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -271,11 +271,7 @@ public function stop() setcookie( $this->sessionCookieName, session_id(), - 1, - $this->cookie->getPath(), - $this->cookie->getDomain(), - $this->cookie->isSecure(), - true + ['expires' => 1, 'path' => $this->cookie->getPath(), 'domain' => $this->cookie->getDomain(), 'secure' => $this->cookie->isSecure(), 'httponly' => true] ); session_regenerate_id(true); @@ -310,7 +306,7 @@ protected function configure() if (! isset($this->sessionExpiration)) { $this->sessionExpiration = (int) ini_get('session.gc_maxlifetime'); - } else { + } elseif ($this->sessionExpiration > 0) { ini_set('session.gc_maxlifetime', (string) $this->sessionExpiration); } diff --git a/system/Test/Mock/MockAppConfig.php b/system/Test/Mock/MockAppConfig.php index 26fb350db12c..15aff95809f0 100644 --- a/system/Test/Mock/MockAppConfig.php +++ b/system/Test/Mock/MockAppConfig.php @@ -15,31 +15,24 @@ class MockAppConfig extends App { - public $baseURL = 'http://example.com/'; - - public $uriProtocol = 'REQUEST_URI'; - - public $cookiePrefix = ''; - public $cookieDomain = ''; - public $cookiePath = '/'; - public $cookieSecure = false; - public $cookieHTTPOnly = false; - public $cookieSameSite = 'Lax'; - - public $proxyIPs = ''; - - public $CSRFProtection = false; - public $CSRFTokenName = 'csrf_test_name'; - public $CSRFHeaderName = 'X-CSRF-TOKEN'; - public $CSRFCookieName = 'csrf_cookie_name'; - public $CSRFExpire = 7200; - public $CSRFRegenerate = true; - public $CSRFExcludeURIs = ['http://example.com']; - public $CSRFRedirect = false; - public $CSRFSameSite = 'Lax'; - - public $CSPEnabled = false; - + public $baseURL = 'http://example.com/'; + public $uriProtocol = 'REQUEST_URI'; + public $cookiePrefix = ''; + public $cookieDomain = ''; + public $cookiePath = '/'; + public $cookieSecure = false; + public $cookieHTTPOnly = false; + public $cookieSameSite = 'Lax'; + public $proxyIPs = ''; + public $CSRFTokenName = 'csrf_test_name'; + public $CSRFHeaderName = 'X-CSRF-TOKEN'; + public $CSRFCookieName = 'csrf_cookie_name'; + public $CSRFExpire = 7200; + public $CSRFRegenerate = true; + public $CSRFExcludeURIs = ['http://example.com']; + public $CSRFRedirect = false; + public $CSRFSameSite = 'Lax'; + public $CSPEnabled = false; public $defaultLocale = 'en'; public $negotiateLocale = false; public $supportedLocales = [ diff --git a/system/Test/Mock/MockAutoload.php b/system/Test/Mock/MockAutoload.php index 26a92b2f811c..291974c65be3 100644 --- a/system/Test/Mock/MockAutoload.php +++ b/system/Test/Mock/MockAutoload.php @@ -15,8 +15,7 @@ class MockAutoload extends Autoload { - public $psr4 = []; - + public $psr4 = []; public $classmap = []; public function __construct() diff --git a/system/Test/Mock/MockCLIConfig.php b/system/Test/Mock/MockCLIConfig.php index 5c6e6b5db195..6eb0dd70a8b8 100644 --- a/system/Test/Mock/MockCLIConfig.php +++ b/system/Test/Mock/MockCLIConfig.php @@ -15,29 +15,22 @@ class MockCLIConfig extends App { - public $baseURL = 'http://example.com/'; - - public $uriProtocol = 'REQUEST_URI'; - - public $cookiePrefix = ''; - public $cookieDomain = ''; - public $cookiePath = '/'; - public $cookieSecure = false; - public $cookieHTTPOnly = false; - public $cookieSameSite = 'Lax'; - - public $proxyIPs = ''; - - public $CSRFProtection = false; - public $CSRFTokenName = 'csrf_test_name'; - public $CSRFCookieName = 'csrf_cookie_name'; - public $CSRFExpire = 7200; - public $CSRFRegenerate = true; - public $CSRFExcludeURIs = ['http://example.com']; - public $CSRFSameSite = 'Lax'; - - public $CSPEnabled = false; - + public $baseURL = 'http://example.com/'; + public $uriProtocol = 'REQUEST_URI'; + public $cookiePrefix = ''; + public $cookieDomain = ''; + public $cookiePath = '/'; + public $cookieSecure = false; + public $cookieHTTPOnly = false; + public $cookieSameSite = 'Lax'; + public $proxyIPs = ''; + public $CSRFTokenName = 'csrf_test_name'; + public $CSRFCookieName = 'csrf_cookie_name'; + public $CSRFExpire = 7200; + public $CSRFRegenerate = true; + public $CSRFExcludeURIs = ['http://example.com']; + public $CSRFSameSite = 'Lax'; + public $CSPEnabled = false; public $defaultLocale = 'en'; public $negotiateLocale = false; public $supportedLocales = [ diff --git a/system/Test/Mock/MockCURLRequest.php b/system/Test/Mock/MockCURLRequest.php index 5b7344fe13e5..635db366234c 100644 --- a/system/Test/Mock/MockCURLRequest.php +++ b/system/Test/Mock/MockCURLRequest.php @@ -23,7 +23,6 @@ class MockCURLRequest extends CURLRequest { public $curl_options; - protected $output = ''; public function setOutput($output) diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index e6bc200fdd1f..ebdacccbbef5 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -14,6 +14,7 @@ use Closure; use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; +use PHPUnit\Framework\Assert; class MockCache extends BaseHandler implements CacheInterface { @@ -31,6 +32,13 @@ class MockCache extends BaseHandler implements CacheInterface */ protected $expirations = []; + /** + * If true, will not cache any data. + * + * @var bool + */ + protected $bypass = false; + /** * Takes care of any handler-specific setup that must be done. */ @@ -49,16 +57,12 @@ public function get(string $key) { $key = static::validateKey($key, $this->prefix); - return $this->cache[$key] ?? null; + return array_key_exists($key, $this->cache) ? $this->cache[$key] : null; } /** * Get an item from the cache, or execute the given Closure and store the result. * - * @param string $key Cache item name - * @param int $ttl Time to live - * @param Closure $callback Callback return value - * * @return mixed */ public function remember(string $key, int $ttl, Closure $callback) @@ -89,6 +93,10 @@ public function remember(string $key, int $ttl, Closure $callback) */ public function save(string $key, $value, int $ttl = 60, bool $raw = false) { + if ($this->bypass) { + return false; + } + $key = static::validateKey($key, $this->prefix); $this->cache[$key] = $value; @@ -100,8 +108,6 @@ public function save(string $key, $value, int $ttl = 60, bool $raw = false) /** * Deletes a specific item from the cache store. * - * @param string $key Cache item name - * * @return bool */ public function delete(string $key) @@ -120,8 +126,6 @@ public function delete(string $key) /** * Deletes items from the cache store matching a given pattern. * - * @param string $pattern Cache items glob-style pattern - * * @return int */ public function deleteMatching(string $pattern) @@ -141,9 +145,6 @@ public function deleteMatching(string $pattern) /** * Performs atomic incrementation of a raw stored value. * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * * @return bool */ public function increment(string $key, int $offset = 1) @@ -163,9 +164,6 @@ public function increment(string $key, int $offset = 1) /** * Performs atomic decrementation of a raw stored value. * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * * @return bool */ public function decrement(string $key, int $offset = 1) @@ -212,10 +210,7 @@ public function getCacheInfo() /** * Returns detailed information about the specific item in the cache. * - * @param string $key Cache item name. - * - * @return array|null - * Returns null if the item does not exist, otherwise array + * @return array|null Returns null if the item does not exist, otherwise array * with at least the 'expire' key for absolute epoch expiry (or null). */ public function getMetaData(string $key) @@ -230,16 +225,73 @@ public function getMetaData(string $key) return null; } - return [ - 'expire' => $this->expirations[$key], - ]; + return ['expire' => $this->expirations[$key]]; } /** - * Determines if the driver is supported on this system. + * Determine if the driver is supported on this system. */ public function isSupported(): bool { return true; } + + //-------------------------------------------------------------------- + // Test Helpers + //-------------------------------------------------------------------- + + /** + * Instructs the class to ignore all + * requests to cache an item, and always "miss" + * when checked for existing data. + * + * @return $this + */ + public function bypass(bool $bypass = true) + { + $this->clean(); + $this->bypass = $bypass; + + return $this; + } + + //-------------------------------------------------------------------- + // Additional Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that the cache has an item named $key. + * The value is not checked since storing false or null + * values is valid. + */ + public function assertHas(string $key) + { + Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`"); + } + + /** + * Asserts that the cache has an item named $key with a value matching $value. + * + * @param mixed $value + */ + public function assertHasValue(string $key, $value = null) + { + $item = $this->get($key); + + // Let assertHas handle throwing the error for consistency + // if the key is not found + if (empty($item)) { + $this->assertHas($key); + } + + Assert::assertSame($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true)); + } + + /** + * Asserts that the cache does NOT have an item named $key. + */ + public function assertMissing(string $key) + { + Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists."); + } } diff --git a/system/Test/Mock/MockConnection.php b/system/Test/Mock/MockConnection.php index 099a8b735d20..78bd405babad 100644 --- a/system/Test/Mock/MockConnection.php +++ b/system/Test/Mock/MockConnection.php @@ -19,9 +19,7 @@ class MockConnection extends BaseConnection { protected $returnValues = []; - public $database; - public $lastQuery; public function shouldReturn(string $method, $return) diff --git a/system/Test/Mock/MockSecurity.php b/system/Test/Mock/MockSecurity.php index 3f802a7d79f6..e24221c17b47 100644 --- a/system/Test/Mock/MockSecurity.php +++ b/system/Test/Mock/MockSecurity.php @@ -11,15 +11,12 @@ namespace CodeIgniter\Test\Mock; -use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\Security\Security; class MockSecurity extends Security { - protected function sendCookie(RequestInterface $request) + protected function doSendCookie(): void { $_COOKIE['csrf_cookie_name'] = $this->hash; - - return $this; } } diff --git a/system/Test/Mock/MockServices.php b/system/Test/Mock/MockServices.php index f02404a1b74e..f18700426811 100644 --- a/system/Test/Mock/MockServices.php +++ b/system/Test/Mock/MockServices.php @@ -19,7 +19,6 @@ class MockServices extends BaseService public $psr4 = [ 'Tests/Support' => TESTPATH . '_support/', ]; - public $classmap = []; public function __construct() diff --git a/system/Validation/CreditCardRules.php b/system/Validation/CreditCardRules.php index 8632dfc8f47e..9e18f4ef5cb9 100644 --- a/system/Validation/CreditCardRules.php +++ b/system/Validation/CreditCardRules.php @@ -189,7 +189,7 @@ public function valid_cc_number(?string $ccNumber, string $type): bool } // Make sure we have a valid length - if (strlen($ccNumber) === 0) { + if ((string) $ccNumber === '') { return false; } diff --git a/system/Validation/FormatRules.php b/system/Validation/FormatRules.php index 6ee13c3f31df..2167e28cba79 100644 --- a/system/Validation/FormatRules.php +++ b/system/Validation/FormatRules.php @@ -268,7 +268,10 @@ public function valid_ip(?string $ip = null, ?string $which = null): bool } /** - * Checks a URL to ensure it's formed correctly. + * Checks a string to ensure it is (loosely) a URL. + * + * Warning: this rule will pass basic strings like + * "banana"; use valid_url_strict for a stricter rule. * * @param string $str */ @@ -291,6 +294,27 @@ public function valid_url(?string $str = null): bool return filter_var($str, FILTER_VALIDATE_URL) !== false; } + /** + * Checks a URL to ensure it's formed correctly. + * + * @param string|null $validSchemes comma separated list of allowed schemes + */ + public function valid_url_strict(?string $str = null, ?string $validSchemes = null): bool + { + if (empty($str)) { + return false; + } + + $scheme = strtolower(parse_url($str, PHP_URL_SCHEME)); + $validSchemes = explode( + ',', + strtolower($validSchemes ?? 'http,https') + ); + + return in_array($scheme, $validSchemes, true) + && filter_var($str, FILTER_VALIDATE_URL) !== false; + } + /** * Checks for a valid date and matches a given date format * diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index a41e1ebc558f..8bd14817e79d 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -291,6 +291,8 @@ protected function processRules(string $field, ?string $label, $value, $rules = // if the $value is an array, convert it to as string representation if (is_array($value)) { $value = '[' . implode(', ', $value) . ']'; + } elseif (is_object($value)) { + $value = json_encode($value); } $this->errors[$field] = $error ?? $this->getErrorMessage($rule, $field, $label, $param, $value); @@ -672,20 +674,41 @@ protected function getErrorMessage(string $rule, string $field, ?string $label = */ protected function splitRules(string $rules): array { - $nonEscapeBracket = '((?>Testing>>Testing Your Database* section of the documentation. +[Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) section of the documentation. If you want to run the tests without using live database you can -exclude @DatabaseLive group. Or make a copy of **phpunit.dist.xml** - -call it **phpunit.xml** - and comment out the named "database". This will make +exclude `@DatabaseLive` group. Or make a copy of **phpunit.dist.xml** - +call it **phpunit.xml** - and comment out the `` named `Database`. This will make the tests run quite a bit faster. ## Running the tests @@ -47,11 +47,11 @@ directory name after phpunit. All core tests are stored under **tests/system**. Individual tests can be run by including the relative path to the test file. - > ./phpunit tests/system/HTTP/RequestTest + > ./phpunit tests/system/HTTP/RequestTest.php -You can run the tests without running the live database tests. +You can run the tests without running the live database and the live cache tests. - > ./phpunit --exclude-group DatabaseLive + > ./phpunit --exclude-group DatabaseLive,CacheLive ## Generating Code Coverage diff --git a/tests/_support/Commands/AppInfo.php b/tests/_support/Commands/AppInfo.php index 562cc9dd3847..e7bf3093e658 100644 --- a/tests/_support/Commands/AppInfo.php +++ b/tests/_support/Commands/AppInfo.php @@ -14,6 +14,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\CodeIgniter; +use RuntimeException; class AppInfo extends BaseCommand { @@ -31,7 +32,7 @@ public function bomb() { try { CLI::color('test', 'white', 'Background'); - } catch (\RuntimeException $oops) { + } catch (RuntimeException $oops) { $this->showError($oops); } } diff --git a/tests/_support/Commands/InvalidCommand.php b/tests/_support/Commands/InvalidCommand.php index a6d13d6fe494..5b4acf936b08 100644 --- a/tests/_support/Commands/InvalidCommand.php +++ b/tests/_support/Commands/InvalidCommand.php @@ -14,6 +14,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\CodeIgniter; +use ReflectionException; class InvalidCommand extends BaseCommand { @@ -23,7 +24,7 @@ class InvalidCommand extends BaseCommand public function __construct() { - throw new \ReflectionException(); + throw new ReflectionException(); } public function run(array $params) diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index 4a9f3b0fe981..989b406655b1 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -111,7 +111,7 @@ public static function Database() { $config = []; - // Under Github Actions, we can set an ENV var named 'DB' + // Under GitHub Actions, we can set an ENV var named 'DB' // so that we can test against multiple databases. if ($group = getenv('DB')) { if (! empty(self::$dbConfig[$group])) { @@ -121,4 +121,18 @@ public static function Database() return $config; } + + /** + * Demonstrates Publisher security. + * + * @see PublisherRestrictionsTest::testRegistrarsNotAllowed() + * + * @return array + */ + public static function Publisher() + { + return [ + 'restrictions' => [SUPPORTPATH => '*'], + ]; + } } diff --git a/tests/_support/Controllers/Popcorn.php b/tests/_support/Controllers/Popcorn.php index a0aa972c635f..71a316685a32 100644 --- a/tests/_support/Controllers/Popcorn.php +++ b/tests/_support/Controllers/Popcorn.php @@ -13,6 +13,7 @@ use CodeIgniter\API\ResponseTrait; use CodeIgniter\Controller; +use RuntimeException; /** * This is a testing only controller, intended to blow up in multiple @@ -34,7 +35,7 @@ public function pop() public function popper() { - throw new \RuntimeException('Surprise', 500); + throw new RuntimeException('Surprise', 500); } public function weasel() diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 07b61cdd2451..cf517e988ebc 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -70,6 +70,7 @@ public function up() 'type_double' => ['type' => 'DOUBLE', 'null' => true], 'type_decimal' => ['type' => 'DECIMAL', 'constraint' => '18,4', 'null' => true], 'type_blob' => ['type' => 'BLOB', 'null' => true], + 'type_boolean' => ['type' => 'BOOLEAN', 'null' => true], ]; if ($this->db->DBDriver === 'Postgre') { @@ -127,6 +128,29 @@ public function up() 'ip' => ['type' => 'VARCHAR', 'constraint' => 100], 'ip2' => ['type' => 'VARCHAR', 'constraint' => 100], ])->createTable('ip_table', true); + + // Database session table + if ($this->db->DBDriver === 'MySQLi') { + $this->forge->addField([ + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false], + 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL', + 'data' => ['type' => 'BLOB', 'null' => false], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('ci_sessions', true); + } + + if ($this->db->DBDriver === 'Postgre') { + $this->forge->addField([ + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address inet NOT NULL', + 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL', + "data bytea DEFAULT '' NOT NULL", + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('ci_sessions', true); + } } public function down() @@ -140,5 +164,9 @@ public function down() $this->forge->dropTable('stringifypkey', true); $this->forge->dropTable('without_auto_increment', true); $this->forge->dropTable('ip_table', true); + + if (in_array($this->db->DBDriver, ['MySQLi', 'Postgre'], true)) { + $this->forge->dropTable('ci_sessions', true); + } } } diff --git a/tests/_support/Database/Seeds/CITestSeeder.php b/tests/_support/Database/Seeds/CITestSeeder.php index 0319896159ab..372697aaa6ba 100644 --- a/tests/_support/Database/Seeds/CITestSeeder.php +++ b/tests/_support/Database/Seeds/CITestSeeder.php @@ -106,6 +106,7 @@ public function run() 'type_datetime' => '2020-06-18T05:12:24.000+02:00', 'type_timestamp' => '2019-07-18T21:53:21.000+02:00', 'type_bigint' => 2342342, + 'type_boolean' => 1, ], ], ]; @@ -119,7 +120,8 @@ public function run() } if ($this->db->DBDriver === 'Postgre') { - $data['type_test'][0]['type_time'] = '15:22:00'; + $data['type_test'][0]['type_time'] = '15:22:00'; + $data['type_test'][0]['type_boolean'] = true; unset( $data['type_test'][0]['type_enum'], $data['type_test'][0]['type_set'], @@ -146,6 +148,24 @@ public function run() ); } + if ($this->db->DBDriver === 'MySQLi') { + $data['ci_sessions'][] = [ + 'id' => '1f5o06b43phsnnf8if6bo33b635e4p2o', + 'ip_address' => '127.0.0.1', + 'timestamp' => '2021-06-25 21:54:14', + 'data' => '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";', + ]; + } + + if ($this->db->DBDriver === 'Postgre') { + $data['ci_sessions'][] = [ + 'id' => '1f5o06b43phsnnf8if6bo33b635e4p2o', + 'ip_address' => '127.0.0.1', + 'timestamp' => '2021-06-25 21:54:14.991403+02', + 'data' => '\x' . bin2hex('__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'), + ]; + } + foreach ($data as $table => $dummy_data) { $this->db->table($table)->truncate(); diff --git a/tests/_support/Models/EntityModel.php b/tests/_support/Models/EntityModel.php index fb53549e8fa6..f2bbe35ccfd1 100644 --- a/tests/_support/Models/EntityModel.php +++ b/tests/_support/Models/EntityModel.php @@ -15,17 +15,12 @@ class EntityModel extends Model { - protected $table = 'job'; - - protected $returnType = '\Tests\Support\Models\SimpleEntity'; - + protected $table = 'job'; + protected $returnType = '\Tests\Support\Models\SimpleEntity'; protected $useSoftDeletes = false; - - protected $dateFormat = 'int'; - - protected $deletedField = 'deleted_at'; - - protected $allowedFields = [ + protected $dateFormat = 'int'; + protected $deletedField = 'deleted_at'; + protected $allowedFields = [ 'name', 'description', 'created_at', diff --git a/tests/_support/Models/EventModel.php b/tests/_support/Models/EventModel.php index 8b8a947efcc6..c3e0a145509b 100644 --- a/tests/_support/Models/EventModel.php +++ b/tests/_support/Models/EventModel.php @@ -15,21 +15,16 @@ class EventModel extends Model { - protected $table = 'user'; - - protected $returnType = 'array'; - + protected $table = 'user'; + protected $returnType = 'array'; protected $useSoftDeletes = false; - - protected $dateFormat = 'datetime'; - - protected $allowedFields = [ + protected $dateFormat = 'datetime'; + protected $allowedFields = [ 'name', 'email', 'country', 'deleted_at', ]; - protected $beforeInsert = ['beforeInsertMethod']; protected $afterInsert = ['afterInsertMethod']; protected $beforeUpdate = ['beforeUpdateMethod']; diff --git a/tests/_support/Models/FabricatorModel.php b/tests/_support/Models/FabricatorModel.php index 1c40e7e1464e..70efeeb5c942 100644 --- a/tests/_support/Models/FabricatorModel.php +++ b/tests/_support/Models/FabricatorModel.php @@ -16,17 +16,12 @@ class FabricatorModel extends Model { - protected $table = 'job'; - - protected $returnType = 'object'; - + protected $table = 'job'; + protected $returnType = 'object'; protected $useSoftDeletes = true; - - protected $useTimestamps = true; - - protected $dateFormat = 'int'; - - protected $allowedFields = [ + protected $useTimestamps = true; + protected $dateFormat = 'int'; + protected $allowedFields = [ 'name', 'description', ]; diff --git a/tests/_support/Models/JobModel.php b/tests/_support/Models/JobModel.php index 06b967af7a89..7f5044dddc61 100644 --- a/tests/_support/Models/JobModel.php +++ b/tests/_support/Models/JobModel.php @@ -15,20 +15,14 @@ class JobModel extends Model { - protected $table = 'job'; - - protected $returnType = 'object'; - + protected $table = 'job'; + protected $returnType = 'object'; protected $useSoftDeletes = false; - - protected $dateFormat = 'int'; - - protected $allowedFields = [ + protected $dateFormat = 'int'; + protected $allowedFields = [ 'name', 'description', ]; - - public $name = ''; - + public $name = ''; public $description = ''; } diff --git a/tests/_support/Models/SecondaryModel.php b/tests/_support/Models/SecondaryModel.php index b4ef155a5aa9..aff1c2646e14 100644 --- a/tests/_support/Models/SecondaryModel.php +++ b/tests/_support/Models/SecondaryModel.php @@ -15,17 +15,12 @@ class SecondaryModel extends Model { - protected $table = 'secondary'; - - protected $primaryKey = 'id'; - - protected $returnType = 'object'; - + protected $table = 'secondary'; + protected $primaryKey = 'id'; + protected $returnType = 'object'; protected $useSoftDeletes = false; - - protected $dateFormat = 'int'; - - protected $allowedFields = [ + protected $dateFormat = 'int'; + protected $allowedFields = [ 'key', 'value', ]; diff --git a/tests/_support/Models/UserModel.php b/tests/_support/Models/UserModel.php index 7e68c3441803..07c678fb5db5 100644 --- a/tests/_support/Models/UserModel.php +++ b/tests/_support/Models/UserModel.php @@ -15,24 +15,17 @@ class UserModel extends Model { - protected $table = 'user'; - + protected $table = 'user'; protected $allowedFields = [ 'name', 'email', 'country', 'deleted_at', ]; - - protected $returnType = 'object'; - + protected $returnType = 'object'; protected $useSoftDeletes = true; - - protected $dateFormat = 'datetime'; - - public $name = ''; - - public $email = ''; - - public $country = ''; + protected $dateFormat = 'datetime'; + public $name = ''; + public $email = ''; + public $country = ''; } diff --git a/tests/_support/Models/ValidErrorsModel.php b/tests/_support/Models/ValidErrorsModel.php index 04ff1a98f715..e05f801a6983 100644 --- a/tests/_support/Models/ValidErrorsModel.php +++ b/tests/_support/Models/ValidErrorsModel.php @@ -15,19 +15,14 @@ class ValidErrorsModel extends Model { - protected $table = 'job'; - - protected $returnType = 'object'; - + protected $table = 'job'; + protected $returnType = 'object'; protected $useSoftDeletes = false; - - protected $dateFormat = 'int'; - - protected $allowedFields = [ + protected $dateFormat = 'int'; + protected $allowedFields = [ 'name', 'description', ]; - protected $validationRules = [ 'name' => [ 'required', diff --git a/tests/_support/Models/ValidModel.php b/tests/_support/Models/ValidModel.php index 730985ad8142..216f80cc3f87 100644 --- a/tests/_support/Models/ValidModel.php +++ b/tests/_support/Models/ValidModel.php @@ -15,19 +15,14 @@ class ValidModel extends Model { - protected $table = 'job'; - - protected $returnType = 'object'; - + protected $table = 'job'; + protected $returnType = 'object'; protected $useSoftDeletes = false; - - protected $dateFormat = 'int'; - - protected $allowedFields = [ + protected $dateFormat = 'int'; + protected $allowedFields = [ 'name', 'description', ]; - protected $validationRules = [ 'name' => [ 'required', @@ -35,7 +30,6 @@ class ValidModel extends Model ], 'token' => 'permit_empty|in_list[{id}]', ]; - protected $validationMessages = [ 'name' => [ 'required' => 'You forgot to name the baby.', diff --git a/tests/_support/Models/WithoutAutoIncrementModel.php b/tests/_support/Models/WithoutAutoIncrementModel.php index 248fd3af1134..dcbb8c4aba3c 100644 --- a/tests/_support/Models/WithoutAutoIncrementModel.php +++ b/tests/_support/Models/WithoutAutoIncrementModel.php @@ -15,14 +15,11 @@ class WithoutAutoIncrementModel extends Model { - protected $table = 'without_auto_increment'; - - protected $primaryKey = 'key'; - + protected $table = 'without_auto_increment'; + protected $primaryKey = 'key'; protected $allowedFields = [ 'key', 'value', ]; - protected $useAutoIncrement = false; } diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php new file mode 100644 index 000000000000..95d9fa72c250 --- /dev/null +++ b/tests/_support/Publishers/TestPublisher.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Publishers; + +use CodeIgniter\Publisher\Publisher; + +final class TestPublisher extends Publisher +{ + /** + * Return value for publish() + * + * @var bool + */ + private static $result = true; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = SUPPORTPATH . 'Files'; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = WRITEPATH; + + /** + * Fakes an error on the given file. + */ + public static function setResult(bool $result) + { + self::$result = $result; + } + + /** + * Fakes a publish event so no files are actually copied. + */ + public function publish(): bool + { + $this->addPath(''); + + return self::$result; + } +} diff --git a/tests/_support/Validation/TestRules.php b/tests/_support/Validation/TestRules.php index ca9b672f583e..507d17ef89bf 100644 --- a/tests/_support/Validation/TestRules.php +++ b/tests/_support/Validation/TestRules.php @@ -19,4 +19,17 @@ public function customError(string $str, ?string &$error = null) return false; } + + public function check_object_rule(object $value, ?string $fields, array $data = []) + { + $find = false; + + foreach ($value as $key => $val) { + if ($key === 'first') { + $find = true; + } + } + + return $find; + } } diff --git a/tests/_support/Widgets/SomeWidget.php b/tests/_support/Widgets/SomeWidget.php index 380449f14b9b..c6e2b3da8652 100644 --- a/tests/_support/Widgets/SomeWidget.php +++ b/tests/_support/Widgets/SomeWidget.php @@ -11,7 +11,9 @@ namespace Tests\Support\Widgets; +use stdClass; + // Extends a trivial class to test the instanceOf directive -class SomeWidget extends \stdClass +class SomeWidget extends stdClass { } diff --git a/tests/_support/coverage.txt b/tests/_support/coverage.txt deleted file mode 100644 index 0fb8b92bb758..000000000000 --- a/tests/_support/coverage.txt +++ /dev/null @@ -1,70 +0,0 @@ - - -Code Coverage Report: - 2016-03-31 06:36:22 - - Summary: - Classes: 29.51% (18/61) - Methods: 42.91% (227/529) - Lines: 53.58% (1967/3671) - -\CodeIgniter::CodeIgniter - Methods: 28.57% ( 4/14) Lines: 69.11% ( 85/123) -\CodeIgniter::Controller - Methods: 0.00% ( 0/ 2) Lines: 66.67% ( 6/ 9) -\CodeIgniter\Autoloader::Autoloader - Methods: 42.86% ( 3/ 7) Lines: 89.36% ( 42/ 47) -\CodeIgniter\Autoloader::FileLocator - Methods: 100.00% ( 3/ 3) Lines: 100.00% ( 39/ 39) -\CodeIgniter\CLI::CLI - Methods: 6.25% ( 1/16) Lines: 2.54% ( 3/118) -\CodeIgniter\Config::AutoloadConfig - Methods: 100.00% ( 1/ 1) Lines: 100.00% ( 52/ 52) -\CodeIgniter\Config::BaseConfig - Methods: 50.00% ( 1/ 2) Lines: 83.33% ( 15/ 18) -\CodeIgniter\Config::DotEnv - Methods: 62.50% ( 5/ 8) Lines: 89.29% ( 50/ 56) -\CodeIgniter\Database::BaseBuilder - Methods: 43.33% (39/90) Lines: 62.25% (460/739) -\CodeIgniter\Database::BaseConnection - Methods: 0.00% ( 0/17) Lines: 3.33% ( 2/ 60) -\CodeIgniter\Database::BaseQuery - Methods: 64.71% (11/17) Lines: 74.44% ( 67/ 90) -\CodeIgniter\Debug::Exceptions - Methods: 10.00% ( 1/10) Lines: 2.91% ( 3/103) -\CodeIgniter\Debug::Timer - Methods: 75.00% ( 3/ 4) Lines: 95.24% ( 20/ 21) -\CodeIgniter\HTTP::CLIRequest - Methods: 33.33% ( 2/ 6) Lines: 40.00% ( 14/ 35) -\CodeIgniter\HTTP::CURLRequest - Methods: 62.50% (10/16) Lines: 68.49% (100/146) -\CodeIgniter\HTTP::Header - Methods: 88.89% ( 8/ 9) Lines: 96.88% ( 31/ 32) -\CodeIgniter\HTTP::IncomingRequest - Methods: 60.00% ( 6/10) Lines: 54.05% ( 40/ 74) -\CodeIgniter\HTTP::Message - Methods: 78.57% (11/14) Lines: 86.21% ( 50/ 58) -\CodeIgniter\HTTP::Negotiate - Methods: 75.00% ( 9/12) Lines: 87.65% ( 71/ 81) -\CodeIgniter\HTTP::Request - Methods: 57.14% ( 4/ 7) Lines: 45.79% ( 49/107) -\CodeIgniter\HTTP::Response - Methods: 53.85% ( 7/13) Lines: 79.01% ( 64/ 81) -\CodeIgniter\HTTP::URI - Methods: 81.82% (27/33) Lines: 93.44% (171/183) -\CodeIgniter\HTTP\Files::FileCollection - Methods: 66.67% ( 4/ 6) Lines: 96.49% ( 55/ 57) -\CodeIgniter\HTTP\Files::UploadedFile - Methods: 42.86% ( 6/14) Lines: 39.22% ( 20/ 51) -\CodeIgniter\Hooks::Hooks - Methods: 66.67% ( 4/ 6) Lines: 92.31% ( 36/ 39) -\CodeIgniter\Log::Logger - Methods: 76.92% (10/13) Lines: 92.05% ( 81/ 88) -\CodeIgniter\Router::RouteCollection - Methods: 74.36% (29/39) Lines: 79.64% (133/167) -\CodeIgniter\Router::Router - Methods: 37.50% ( 6/16) Lines: 78.57% ( 77/ 98) -\CodeIgniter\Security::Security - Methods: 100.00% ( 6/ 6) Lines: 100.00% ( 50/ 50) -\CodeIgniter\View::View - Methods: 85.71% ( 6/ 7) Lines: 97.14% ( 34/ 35) diff --git a/tests/system/Autoloader/FileLocatorTest.php b/tests/system/Autoloader/FileLocatorTest.php index 8a8b91aeea90..c85e4c215ba2 100644 --- a/tests/system/Autoloader/FileLocatorTest.php +++ b/tests/system/Autoloader/FileLocatorTest.php @@ -261,7 +261,7 @@ public function testFindQNameFromPathWithoutCorrespondingNamespace() public function testGetClassNameFromClassFile() { $this->assertSame( - __CLASS__, + self::class, $this->locator->getClassname(__FILE__) ); } diff --git a/tests/system/Cache/CacheMockTest.php b/tests/system/Cache/CacheMockTest.php new file mode 100644 index 000000000000..860623478693 --- /dev/null +++ b/tests/system/Cache/CacheMockTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\Cache\Handlers\BaseHandler; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockCache; + +/** + * @internal + */ +final class CacheMockTest extends CIUnitTestCase +{ + public function testMockReturnsMockCacheClass() + { + $this->assertInstanceOf(BaseHandler::class, service('cache')); + + $mock = mock(CacheFactory::class); + $this->assertInstanceOf(MockCache::class, $mock); + $this->assertInstanceOf(MockCache::class, service('cache')); + } + + public function testMockCaching() + { + $mock = mock(CacheFactory::class); + + // Ensure it stores the value normally + $mock->save('foo', 'bar'); + $mock->assertHas('foo'); + $mock->assertHasValue('foo', 'bar'); + + // Try it again with bypass on + $mock->bypass(); + $mock->save('foo', 'bar'); + $mock->assertMissing('foo'); + } +} diff --git a/tests/system/Cache/Handlers/AbstractHandlerTest.php b/tests/system/Cache/Handlers/AbstractHandlerTest.php new file mode 100644 index 000000000000..7ff02a729b49 --- /dev/null +++ b/tests/system/Cache/Handlers/AbstractHandlerTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +abstract class AbstractHandlerTest extends CIUnitTestCase +{ + protected $handler; + protected static $key1 = 'key1'; + protected static $key2 = 'key2'; + protected static $key3 = 'key3'; + protected static $dummy = 'dymmy'; + + public function testGetMetaDataMiss() + { + $this->assertNull($this->handler->getMetaData(self::$dummy)); + } + + public function testGetMetaData() + { + $time = time(); + $this->handler->save(self::$key1, 'value'); + + $actual = $this->handler->getMetaData(self::$key1); + + // This test is time-dependent, and depending on the timing, + // seconds in `$time` (e.g. 12:00:00.9999) and seconds of + // `$this->memcachedHandler->save()` (e.g. 12:00:01.0000) + // may be off by one second. In that case, the following calculation + // will result in maximum of (60 + 1). + $this->assertLessThanOrEqual(60 + 1, $actual['expire'] - $time); + + $this->assertLessThanOrEqual(1, $actual['mtime'] - $time); + $this->assertSame('value', $actual['data']); + } +} diff --git a/tests/system/Cache/Handlers/BaseHandlerTest.php b/tests/system/Cache/Handlers/BaseHandlerTest.php index 5965666a5810..b2ebb61e01ef 100644 --- a/tests/system/Cache/Handlers/BaseHandlerTest.php +++ b/tests/system/Cache/Handlers/BaseHandlerTest.php @@ -44,6 +44,16 @@ public function invalidTypeProvider(): array ]; } + public function testValidateKeyUsesConfig() + { + config('Cache')->reservedCharacters = 'b'; + + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Cache key contains reserved characters b'); + + BaseHandler::validateKey('banana'); + } + public function testValidateKeySuccess() { $string = 'banana'; diff --git a/tests/system/Cache/Handlers/DummyHandlerTest.php b/tests/system/Cache/Handlers/DummyHandlerTest.php index c0fc5eb20cf5..482205d68d20 100644 --- a/tests/system/Cache/Handlers/DummyHandlerTest.php +++ b/tests/system/Cache/Handlers/DummyHandlerTest.php @@ -18,27 +18,27 @@ */ final class DummyHandlerTest extends CIUnitTestCase { - private $dummyHandler; + private $handler; protected function setUp(): void { - $this->dummyHandler = new DummyHandler(); - $this->dummyHandler->initialize(); + $this->handler = new DummyHandler(); + $this->handler->initialize(); } public function testNew() { - $this->assertInstanceOf(DummyHandler::class, $this->dummyHandler); + $this->assertInstanceOf(DummyHandler::class, $this->handler); } public function testGet() { - $this->assertNull($this->dummyHandler->get('key')); + $this->assertNull($this->handler->get('key')); } public function testRemember() { - $dummyHandler = $this->dummyHandler->remember('key', 2, static function () { + $dummyHandler = $this->handler->remember('key', 2, static function () { return 'value'; }); @@ -47,46 +47,46 @@ public function testRemember() public function testSave() { - $this->assertTrue($this->dummyHandler->save('key', 'value')); + $this->assertTrue($this->handler->save('key', 'value')); } public function testDelete() { - $this->assertTrue($this->dummyHandler->delete('key')); + $this->assertTrue($this->handler->delete('key')); } public function testDeleteMatching() { - $this->assertSame(0, $this->dummyHandler->deleteMatching('key*')); + $this->assertSame(0, $this->handler->deleteMatching('key*')); } public function testIncrement() { - $this->assertTrue($this->dummyHandler->increment('key')); + $this->assertTrue($this->handler->increment('key')); } public function testDecrement() { - $this->assertTrue($this->dummyHandler->decrement('key')); + $this->assertTrue($this->handler->decrement('key')); } public function testClean() { - $this->assertTrue($this->dummyHandler->clean()); + $this->assertTrue($this->handler->clean()); } public function testGetCacheInfo() { - $this->assertNull($this->dummyHandler->getCacheInfo()); + $this->assertNull($this->handler->getCacheInfo()); } public function testGetMetaData() { - $this->assertNull($this->dummyHandler->getMetaData('key')); + $this->assertNull($this->handler->getMetaData('key')); } public function testIsSupported() { - $this->assertTrue($this->dummyHandler->isSupported()); + $this->assertTrue($this->handler->isSupported()); } } diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index bfd379ae9d8f..f0aa76ea610d 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -12,18 +12,15 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\CLI\CLI; -use CodeIgniter\Test\CIUnitTestCase; use Config\Cache; /** * @internal */ -final class FileHandlerTest extends CIUnitTestCase +final class FileHandlerTest extends AbstractHandlerTest { private static $directory = 'FileHandler'; - private static $key1 = 'key1'; - private static $key2 = 'key2'; - private static $key3 = 'key3'; + private $config; private static function getKeyArray() { @@ -34,10 +31,6 @@ private static function getKeyArray() ]; } - private static $dummy = 'dymmy'; - private $fileHandler; - private $config; - protected function setUp(): void { parent::setUp(); @@ -54,8 +47,8 @@ protected function setUp(): void mkdir($this->config->file['storePath'], 0777, true); } - $this->fileHandler = new FileHandler($this->config); - $this->fileHandler->initialize(); + $this->handler = new FileHandler($this->config); + $this->handler->initialize(); } protected function tearDown(): void @@ -78,7 +71,7 @@ protected function tearDown(): void public function testNew() { - $this->assertInstanceOf(FileHandler::class, $this->fileHandler); + $this->assertInstanceOf(FileHandler::class, $this->handler); } public function testNewWithNonWritablePath() @@ -95,10 +88,10 @@ public function testSetDefaultPath() $config = new Cache(); $config->file['storePath'] = null; - $this->fileHandler = new FileHandler($config); - $this->fileHandler->initialize(); + $this->handler = new FileHandler($config); + $this->handler->initialize(); - $this->assertInstanceOf(FileHandler::class, $this->fileHandler); + $this->assertInstanceOf(FileHandler::class, $this->handler); } /** @@ -109,13 +102,13 @@ public function testSetDefaultPath() */ public function testGet() { - $this->fileHandler->save(self::$key1, 'value', 2); + $this->handler->save(self::$key1, 'value', 2); - $this->assertSame('value', $this->fileHandler->get(self::$key1)); - $this->assertNull($this->fileHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->fileHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } /** @@ -126,23 +119,23 @@ public function testGet() */ public function testRemember() { - $this->fileHandler->remember(self::$key1, 2, static function () { + $this->handler->remember(self::$key1, 2, static function () { return 'value'; }); - $this->assertSame('value', $this->fileHandler->get(self::$key1)); - $this->assertNull($this->fileHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->fileHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } public function testSave() { - $this->assertTrue($this->fileHandler->save(self::$key1, 'value')); + $this->assertTrue($this->handler->save(self::$key1, 'value')); chmod($this->config->file['storePath'], 0444); - $this->assertFalse($this->fileHandler->save(self::$key2, 'value')); + $this->assertFalse($this->handler->save(self::$key2, 'value')); } public function testSaveExcessiveKeyLength() @@ -150,114 +143,113 @@ public function testSaveExcessiveKeyLength() $key = str_repeat('a', 260); $file = $this->config->file['storePath'] . DIRECTORY_SEPARATOR . md5($key); - $this->assertTrue($this->fileHandler->save($key, 'value')); + $this->assertTrue($this->handler->save($key, 'value')); $this->assertFileExists($file); unlink($file); } + public function testSavePermanent() + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); + } + public function testDelete() { - $this->fileHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $this->assertTrue($this->fileHandler->delete(self::$key1)); - $this->assertFalse($this->fileHandler->delete(self::$dummy)); + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); } public function testDeleteMatchingPrefix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->fileHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $this->assertCount(101, $this->fileHandler->getCacheInfo()); + $this->assertCount(101, $this->handler->getCacheInfo()); // Checking that given the prefix "key_1", deleteMatching deletes 13 keys: // (key_1, key_10, key_11, key_12, key_13, key_14, key_15, key_16, key_17, key_18, key_19, key_100, key_101) - $this->assertSame(13, $this->fileHandler->deleteMatching('key_1*')); + $this->assertSame(13, $this->handler->deleteMatching('key_1*')); // check that there remains (101 - 13) = 88 items is cache store - $this->assertCount(88, $this->fileHandler->getCacheInfo()); + $this->assertCount(88, $this->handler->getCacheInfo()); // Clear all files - $this->fileHandler->clean(); + $this->handler->clean(); } public function testDeleteMatchingSuffix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->fileHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $this->assertCount(101, $this->fileHandler->getCacheInfo()); + $this->assertCount(101, $this->handler->getCacheInfo()); // Checking that given the suffix "1", deleteMatching deletes 11 keys: // (key_1, key_11, key_21, key_31, key_41, key_51, key_61, key_71, key_81, key_91, key_101) - $this->assertSame(11, $this->fileHandler->deleteMatching('*1')); + $this->assertSame(11, $this->handler->deleteMatching('*1')); // check that there remains (101 - 13) = 88 items is cache store - $this->assertCount(90, $this->fileHandler->getCacheInfo()); + $this->assertCount(90, $this->handler->getCacheInfo()); // Clear all files - $this->fileHandler->clean(); + $this->handler->clean(); } public function testIncrement() { - $this->fileHandler->save(self::$key1, 1); - $this->fileHandler->save(self::$key2, 'value'); + $this->handler->save(self::$key1, 1); + $this->handler->save(self::$key2, 'value'); - $this->assertSame(11, $this->fileHandler->increment(self::$key1, 10)); - $this->assertFalse($this->fileHandler->increment(self::$key2, 10)); - $this->assertSame(10, $this->fileHandler->increment(self::$key3, 10)); + $this->assertSame(11, $this->handler->increment(self::$key1, 10)); + $this->assertFalse($this->handler->increment(self::$key2, 10)); + $this->assertSame(10, $this->handler->increment(self::$key3, 10)); } public function testDecrement() { - $this->fileHandler->save(self::$key1, 10); - $this->fileHandler->save(self::$key2, 'value'); + $this->handler->save(self::$key1, 10); + $this->handler->save(self::$key2, 'value'); // Line following commented out to force the cache to add a zero entry for key3 // $this->fileHandler->save(self::$key3, 0); - $this->assertSame(9, $this->fileHandler->decrement(self::$key1, 1)); - $this->assertFalse($this->fileHandler->decrement(self::$key2, 1)); - $this->assertSame(-1, $this->fileHandler->decrement(self::$key3, 1)); + $this->assertSame(9, $this->handler->decrement(self::$key1, 1)); + $this->assertFalse($this->handler->decrement(self::$key2, 1)); + $this->assertSame(-1, $this->handler->decrement(self::$key3, 1)); } public function testClean() { - $this->fileHandler->save(self::$key1, 1); - $this->fileHandler->save(self::$key2, 'value'); + $this->handler->save(self::$key1, 1); + $this->handler->save(self::$key2, 'value'); - $this->assertTrue($this->fileHandler->clean()); + $this->assertTrue($this->handler->clean()); - $this->fileHandler->save(self::$key1, 1); - $this->fileHandler->save(self::$key2, 'value'); - } - - public function testGetMetaData() - { - $time = time(); - $this->fileHandler->save(self::$key1, 'value'); - - $this->assertFalse($this->fileHandler->getMetaData(self::$dummy)); - - $actual = $this->fileHandler->getMetaData(self::$key1); - $this->assertLessThanOrEqual(60, $actual['expire'] - $time); - $this->assertLessThanOrEqual(1, $actual['mtime'] - $time); - $this->assertSame('value', $actual['data']); + $this->handler->save(self::$key1, 1); + $this->handler->save(self::$key2, 'value'); } public function testGetCacheInfo() { - $this->fileHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $actual = $this->fileHandler->getCacheInfo(); + $actual = $this->handler->getCacheInfo(); $this->assertArrayHasKey(self::$key1, $actual); $this->assertSame(self::$key1, $actual[self::$key1]['name']); $this->assertArrayHasKey('server_path', $actual[self::$key1]); @@ -265,7 +257,7 @@ public function testGetCacheInfo() public function testIsSupported() { - $this->assertTrue($this->fileHandler->isSupported()); + $this->assertTrue($this->handler->isSupported()); } /** @@ -280,10 +272,10 @@ public function testSaveMode($int, $string) $config = new Cache(); $config->file['mode'] = $int; - $this->fileHandler = new FileHandler($config); - $this->fileHandler->initialize(); + $this->handler = new FileHandler($config); + $this->handler->initialize(); - $this->fileHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); $file = $config->file['storePath'] . DIRECTORY_SEPARATOR . self::$key1; $mode = octal_permissions(fileperms($file)); @@ -327,6 +319,11 @@ public function testFileHandler() $this->assertArrayHasKey('executable', $actual); $this->assertArrayHasKey('fileperms', $actual); } + + public function testGetMetaDataMiss() + { + $this->assertFalse($this->handler->getMetaData(self::$dummy)); + } } final class BaseTestFileHandler extends FileHandler diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index f83708bd8c98..38005d87ed5b 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -12,19 +12,17 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\CLI\CLI; -use CodeIgniter\Test\CIUnitTestCase; use Config\Cache; use Exception; /** + * @group CacheLive + * * @internal */ -final class MemcachedHandlerTest extends CIUnitTestCase +final class MemcachedHandlerTest extends AbstractHandlerTest { - private $memcachedHandler; - private static $key1 = 'key1'; - private static $key2 = 'key2'; - private static $key3 = 'key3'; + private $config; private static function getKeyArray() { @@ -35,30 +33,27 @@ private static function getKeyArray() ]; } - private static $dummy = 'dymmy'; - private $config; - protected function setUp(): void { parent::setUp(); $this->config = new Cache(); - $this->memcachedHandler = new MemcachedHandler($this->config); + $this->handler = new MemcachedHandler($this->config); - $this->memcachedHandler->initialize(); + $this->handler->initialize(); } protected function tearDown(): void { foreach (self::getKeyArray() as $key) { - $this->memcachedHandler->delete($key); + $this->handler->delete($key); } } public function testNew() { - $this->assertInstanceOf(MemcachedHandler::class, $this->memcachedHandler); + $this->assertInstanceOf(MemcachedHandler::class, $this->handler); } /** @@ -69,13 +64,13 @@ public function testNew() */ public function testGet() { - $this->memcachedHandler->save(self::$key1, 'value', 2); + $this->handler->save(self::$key1, 'value', 2); - $this->assertSame('value', $this->memcachedHandler->get(self::$key1)); - $this->assertNull($this->memcachedHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->memcachedHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } /** @@ -86,28 +81,40 @@ public function testGet() */ public function testRemember() { - $this->memcachedHandler->remember(self::$key1, 2, static function () { + $this->handler->remember(self::$key1, 2, static function () { return 'value'; }); - $this->assertSame('value', $this->memcachedHandler->get(self::$key1)); - $this->assertNull($this->memcachedHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->memcachedHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } public function testSave() { - $this->assertTrue($this->memcachedHandler->save(self::$key1, 'value')); + $this->assertTrue($this->handler->save(self::$key1, 'value')); + } + + public function testSavePermanent() + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); } public function testDelete() { - $this->memcachedHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $this->assertTrue($this->memcachedHandler->delete(self::$key1)); - $this->assertFalse($this->memcachedHandler->delete(self::$dummy)); + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); } public function testDeleteMatching() @@ -115,14 +122,14 @@ public function testDeleteMatching() // Not implemented for Memcached, should throw an exception $this->expectException(Exception::class); - $this->memcachedHandler->deleteMatching('key*'); + $this->handler->deleteMatching('key*'); } public function testIncrement() { - $this->memcachedHandler->save(self::$key1, 1); + $this->handler->save(self::$key1, 1); - $this->assertFalse($this->memcachedHandler->increment(self::$key1, 10)); + $this->assertFalse($this->handler->increment(self::$key1, 10)); $config = new Cache(); $config->memcached['raw'] = true; @@ -139,9 +146,9 @@ public function testIncrement() public function testDecrement() { - $this->memcachedHandler->save(self::$key1, 10); + $this->handler->save(self::$key1, 10); - $this->assertFalse($this->memcachedHandler->decrement(self::$key1, 1)); + $this->assertFalse($this->handler->decrement(self::$key1, 1)); $config = new Cache(); $config->memcached['raw'] = true; @@ -158,34 +165,26 @@ public function testDecrement() public function testClean() { - $this->memcachedHandler->save(self::$key1, 1); - $this->memcachedHandler->save(self::$key2, 'value'); + $this->handler->save(self::$key1, 1); + $this->handler->save(self::$key2, 'value'); - $this->assertTrue($this->memcachedHandler->clean()); + $this->assertTrue($this->handler->clean()); } public function testGetCacheInfo() { - $this->memcachedHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $this->assertIsArray($this->memcachedHandler->getCacheInfo()); + $this->assertIsArray($this->handler->getCacheInfo()); } - public function testGetMetaData() + public function testIsSupported() { - $time = time(); - $this->memcachedHandler->save(self::$key1, 'value'); - - $this->assertFalse($this->memcachedHandler->getMetaData(self::$dummy)); - - $actual = $this->memcachedHandler->getMetaData(self::$key1); - $this->assertLessThanOrEqual(60, $actual['expire'] - $time); - $this->assertLessThanOrEqual(1, $actual['mtime'] - $time); - $this->assertSame('value', $actual['data']); + $this->assertTrue($this->handler->isSupported()); } - public function testIsSupported() + public function testGetMetaDataMiss() { - $this->assertTrue($this->memcachedHandler->isSupported()); + $this->assertFalse($this->handler->getMetaData(self::$dummy)); } } diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index df6dfdbfbaef..cdc79adf68b4 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -12,18 +12,16 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\CLI\CLI; -use CodeIgniter\Test\CIUnitTestCase; use Config\Cache; /** + * @group CacheLive + * * @internal */ -final class PredisHandlerTest extends CIUnitTestCase +final class PredisHandlerTest extends AbstractHandlerTest { - private $PredisHandler; - private static $key1 = 'key1'; - private static $key2 = 'key2'; - private static $key3 = 'key3'; + private $config; private static function getKeyArray() { @@ -34,38 +32,35 @@ private static function getKeyArray() ]; } - private static $dummy = 'dymmy'; - private $config; - protected function setUp(): void { parent::setUp(); $this->config = new Cache(); - $this->PredisHandler = new PredisHandler($this->config); + $this->handler = new PredisHandler($this->config); - $this->PredisHandler->initialize(); + $this->handler->initialize(); } protected function tearDown(): void { foreach (self::getKeyArray() as $key) { - $this->PredisHandler->delete($key); + $this->handler->delete($key); } } public function testNew() { - $this->assertInstanceOf(PredisHandler::class, $this->PredisHandler); + $this->assertInstanceOf(PredisHandler::class, $this->handler); } public function testDestruct() { - $this->PredisHandler = new PredisHandler($this->config); - $this->PredisHandler->initialize(); + $this->handler = new PredisHandler($this->config); + $this->handler->initialize(); - $this->assertInstanceOf(PredisHandler::class, $this->PredisHandler); + $this->assertInstanceOf(PredisHandler::class, $this->handler); } /** @@ -76,13 +71,13 @@ public function testDestruct() */ public function testGet() { - $this->PredisHandler->save(self::$key1, 'value', 2); + $this->handler->save(self::$key1, 'value', 2); - $this->assertSame('value', $this->PredisHandler->get(self::$key1)); - $this->assertNull($this->PredisHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->PredisHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } /** @@ -93,96 +88,95 @@ public function testGet() */ public function testRemember() { - $this->PredisHandler->remember(self::$key1, 2, static function () { + $this->handler->remember(self::$key1, 2, static function () { return 'value'; }); - $this->assertSame('value', $this->PredisHandler->get(self::$key1)); - $this->assertNull($this->PredisHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->PredisHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } public function testSave() { - $this->assertTrue($this->PredisHandler->save(self::$key1, 'value')); + $this->assertTrue($this->handler->save(self::$key1, 'value')); + } + + public function testSavePermanent() + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); } public function testDelete() { - $this->PredisHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $this->assertTrue($this->PredisHandler->delete(self::$key1)); - $this->assertFalse($this->PredisHandler->delete(self::$dummy)); + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); } public function testDeleteMatchingPrefix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->PredisHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $this->assertSame('101', $this->PredisHandler->getCacheInfo()['Keyspace']['db0']['keys']); + $this->assertSame('101', $this->handler->getCacheInfo()['Keyspace']['db0']['keys']); // Checking that given the prefix "key_1", deleteMatching deletes 13 keys: // (key_1, key_10, key_11, key_12, key_13, key_14, key_15, key_16, key_17, key_18, key_19, key_100, key_101) - $this->assertSame(13, $this->PredisHandler->deleteMatching('key_1*')); + $this->assertSame(13, $this->handler->deleteMatching('key_1*')); // check that there remains (101 - 13) = 88 items is cache store - $this->assertSame('88', $this->PredisHandler->getCacheInfo()['Keyspace']['db0']['keys']); + $this->assertSame('88', $this->handler->getCacheInfo()['Keyspace']['db0']['keys']); } public function testDeleteMatchingSuffix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->PredisHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $this->assertSame('101', $this->PredisHandler->getCacheInfo()['Keyspace']['db0']['keys']); + $this->assertSame('101', $this->handler->getCacheInfo()['Keyspace']['db0']['keys']); // Checking that given the suffix "1", deleteMatching deletes 11 keys: // (key_1, key_11, key_21, key_31, key_41, key_51, key_61, key_71, key_81, key_91, key_101) - $this->assertSame(11, $this->PredisHandler->deleteMatching('*1')); + $this->assertSame(11, $this->handler->deleteMatching('*1')); // check that there remains (101 - 13) = 88 items is cache store - $this->assertSame('90', $this->PredisHandler->getCacheInfo()['Keyspace']['db0']['keys']); + $this->assertSame('90', $this->handler->getCacheInfo()['Keyspace']['db0']['keys']); } public function testClean() { - $this->PredisHandler->save(self::$key1, 1); - $this->PredisHandler->save(self::$key2, 'value'); + $this->handler->save(self::$key1, 1); + $this->handler->save(self::$key2, 'value'); - $this->assertTrue($this->PredisHandler->clean()); + $this->assertTrue($this->handler->clean()); } public function testGetCacheInfo() { - $this->PredisHandler->save(self::$key1, 'value'); - - $this->assertIsArray($this->PredisHandler->getCacheInfo()); - } - - public function testGetMetaData() - { - $time = time(); - $this->PredisHandler->save(self::$key1, 'value'); - - $this->assertNull($this->PredisHandler->getMetaData(self::$dummy)); + $this->handler->save(self::$key1, 'value'); - $actual = $this->PredisHandler->getMetaData(self::$key1); - $this->assertLessThanOrEqual(60, $actual['expire'] - $time); - $this->assertLessThanOrEqual(1, $actual['mtime'] - $time); - $this->assertSame('value', $actual['data']); + $this->assertIsArray($this->handler->getCacheInfo()); } public function testIsSupported() { - $this->assertTrue($this->PredisHandler->isSupported()); + $this->assertTrue($this->handler->isSupported()); } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 66f3c7fd2244..4eb8f8b08560 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -12,18 +12,16 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\CLI\CLI; -use CodeIgniter\Test\CIUnitTestCase; use Config\Cache; /** + * @group CacheLive + * * @internal */ -final class RedisHandlerTest extends CIUnitTestCase +final class RedisHandlerTest extends AbstractHandlerTest { - private $redisHandler; - private static $key1 = 'key1'; - private static $key2 = 'key2'; - private static $key3 = 'key3'; + private $config; private static function getKeyArray() { @@ -34,38 +32,35 @@ private static function getKeyArray() ]; } - private static $dummy = 'dymmy'; - private $config; - protected function setUp(): void { parent::setUp(); $this->config = new Cache(); - $this->redisHandler = new RedisHandler($this->config); + $this->handler = new RedisHandler($this->config); - $this->redisHandler->initialize(); + $this->handler->initialize(); } protected function tearDown(): void { foreach (self::getKeyArray() as $key) { - $this->redisHandler->delete($key); + $this->handler->delete($key); } } public function testNew() { - $this->assertInstanceOf(RedisHandler::class, $this->redisHandler); + $this->assertInstanceOf(RedisHandler::class, $this->handler); } public function testDestruct() { - $this->redisHandler = new RedisHandler($this->config); - $this->redisHandler->initialize(); + $this->handler = new RedisHandler($this->config); + $this->handler->initialize(); - $this->assertInstanceOf(RedisHandler::class, $this->redisHandler); + $this->assertInstanceOf(RedisHandler::class, $this->handler); } /** @@ -76,13 +71,13 @@ public function testDestruct() */ public function testGet() { - $this->redisHandler->save(self::$key1, 'value', 2); + $this->handler->save(self::$key1, 'value', 2); - $this->assertSame('value', $this->redisHandler->get(self::$key1)); - $this->assertNull($this->redisHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->redisHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } /** @@ -93,47 +88,59 @@ public function testGet() */ public function testRemember() { - $this->redisHandler->remember(self::$key1, 2, static function () { + $this->handler->remember(self::$key1, 2, static function () { return 'value'; }); - $this->assertSame('value', $this->redisHandler->get(self::$key1)); - $this->assertNull($this->redisHandler->get(self::$dummy)); + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); CLI::wait(3); - $this->assertNull($this->redisHandler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); } public function testSave() { - $this->assertTrue($this->redisHandler->save(self::$key1, 'value')); + $this->assertTrue($this->handler->save(self::$key1, 'value')); + } + + public function testSavePermanent() + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); } public function testDelete() { - $this->redisHandler->save(self::$key1, 'value'); + $this->handler->save(self::$key1, 'value'); - $this->assertTrue($this->redisHandler->delete(self::$key1)); - $this->assertFalse($this->redisHandler->delete(self::$dummy)); + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); } public function testDeleteMatchingPrefix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->redisHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $dbInfo = explode(',', $this->redisHandler->getCacheInfo()['db0']); + $dbInfo = explode(',', $this->handler->getCacheInfo()['db0']); $this->assertSame('keys=101', $dbInfo[0]); // Checking that given the prefix "key_1", deleteMatching deletes 13 keys: // (key_1, key_10, key_11, key_12, key_13, key_14, key_15, key_16, key_17, key_18, key_19, key_100, key_101) - $this->assertSame(13, $this->redisHandler->deleteMatching('key_1*')); + $this->assertSame(13, $this->handler->deleteMatching('key_1*')); // check that there remains (101 - 13) = 88 items is cache store - $dbInfo = explode(',', $this->redisHandler->getCacheInfo()['db0']); + $dbInfo = explode(',', $this->handler->getCacheInfo()['db0']); $this->assertSame('keys=88', $dbInfo[0]); } @@ -141,19 +148,19 @@ public function testDeleteMatchingSuffix() { // Save 101 items to match on for ($i = 1; $i <= 101; $i++) { - $this->redisHandler->save('key_' . $i, 'value' . $i); + $this->handler->save('key_' . $i, 'value' . $i); } // check that there are 101 items is cache store - $dbInfo = explode(',', $this->redisHandler->getCacheInfo()['db0']); + $dbInfo = explode(',', $this->handler->getCacheInfo()['db0']); $this->assertSame('keys=101', $dbInfo[0]); // Checking that given the suffix "1", deleteMatching deletes 11 keys: // (key_1, key_11, key_21, key_31, key_41, key_51, key_61, key_71, key_81, key_91, key_101) - $this->assertSame(11, $this->redisHandler->deleteMatching('*1')); + $this->assertSame(11, $this->handler->deleteMatching('*1')); // check that there remains (101 - 13) = 88 items is cache store - $dbInfo = explode(',', $this->redisHandler->getCacheInfo()['db0']); + $dbInfo = explode(',', $this->handler->getCacheInfo()['db0']); $this->assertSame('keys=90', $dbInfo[0]); } @@ -168,33 +175,20 @@ public function testDeleteMatchingSuffix() public function testClean() { - $this->redisHandler->save(self::$key1, 1); + $this->handler->save(self::$key1, 1); - $this->assertTrue($this->redisHandler->clean()); + $this->assertTrue($this->handler->clean()); } public function testGetCacheInfo() { - $this->redisHandler->save(self::$key1, 'value'); - - $this->assertIsArray($this->redisHandler->getCacheInfo()); - } - - public function testGetMetaData() - { - $time = time(); - $this->redisHandler->save(self::$key1, 'value'); - - $this->assertNull($this->redisHandler->getMetaData(self::$dummy)); + $this->handler->save(self::$key1, 'value'); - $actual = $this->redisHandler->getMetaData(self::$key1); - $this->assertLessThanOrEqual(60, $actual['expire'] - $time); - $this->assertLessThanOrEqual(1, $actual['mtime'] - $time); - $this->assertSame('value', $actual['data']); + $this->assertIsArray($this->handler->getCacheInfo()); } public function testIsSupported() { - $this->assertTrue($this->redisHandler->isSupported()); + $this->assertTrue($this->handler->isSupported()); } } diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index af0488f3c7f5..f7838105af4d 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\Mock\MockCodeIgniter; use Config\App; use Config\Modules; +use Tests\Support\Filters\Customfilter; /** * @backupGlobals enabled @@ -35,7 +36,7 @@ final class CodeIgniterTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); - Services::reset(); + $this->resetServices(); $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; @@ -172,6 +173,29 @@ public function testControllersCanReturnResponseObject() $this->assertStringContainsString("You want to see 'about' page.", $output); } + public function testControllersRunFilterByClassName() + { + $_SERVER['argv'] = ['index.php', 'pages/about']; + $_SERVER['argc'] = 2; + + $_SERVER['REQUEST_URI'] = '/pages/about'; + + // Inject mock router. + $routes = Services::routes(); + $routes->add('pages/about', static function () { + return Services::request()->url; + }, ['filter' => Customfilter::class]); + + $router = Services::router($routes, Services::request()); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->useSafeOutput(true)->run(); + $output = ob_get_clean(); + + $this->assertStringContainsString('http://hellowworld.com', $output); + } + public function testResponseConfigEmpty() { $_SERVER['argv'] = ['index.php', '/']; @@ -345,7 +369,24 @@ public function testRunRedirectionWithHTTPCode303() $this->assertSame(303, $response->getStatusCode()); } - public function testRunRedirectionWithHTTPCode301() + public function testStoresPreviousURL() + { + $_SERVER['argv'] = ['index.php', '/']; + $_SERVER['argc'] = 2; + + // Inject mock router. + $router = Services::router(null, Services::request(), false); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->useSafeOutput(true)->run(); + ob_get_clean(); + + $this->assertArrayHasKey('_ci_previous_url', $_SESSION); + $this->assertSame('http://example.com/index.php', $_SESSION['_ci_previous_url']); + } + + public function testNotStoresPreviousURL() { $_SERVER['argv'] = ['index.php', 'example']; $_SERVER['argc'] = 2; @@ -364,8 +405,8 @@ public function testRunRedirectionWithHTTPCode301() ob_start(); $this->codeigniter->useSafeOutput(true)->run(); ob_get_clean(); - $response = $this->getPrivateProperty($this->codeigniter, 'response'); - $this->assertSame(301, $response->getStatusCode()); + + $this->assertArrayNotHasKey('_ci_previous_url', $_SESSION); } /** diff --git a/tests/system/Commands/CommandGeneratorTest.php b/tests/system/Commands/CommandGeneratorTest.php index abe3ce4c7e6e..c878b969d5e9 100644 --- a/tests/system/Commands/CommandGeneratorTest.php +++ b/tests/system/Commands/CommandGeneratorTest.php @@ -132,4 +132,24 @@ public function testGeneratorPreservesCaseButChangesComponentName(): void $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $this->assertFileExists(APPPATH . 'Controllers/TestModuleController.php'); } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4857 + */ + public function testGeneratorIsNotConfusedWithNamespaceLikeClassNames(): void + { + $time = time(); + $notExists = true; + command('make:migration App_Lesson'); + + // we got 5 chances to prove that the file created went to app/Database/Migrations + foreach (range(0, 4) as $increment) { + $expectedFile = sprintf('%sDatabase/Migrations/%s_AppLesson.php', APPPATH, gmdate('Y-m-d-His', $time + $increment)); + clearstatcache(true, $expectedFile); + + $notExists = $notExists && ! is_file($expectedFile); + } + + $this->assertFalse($notExists, 'Creating migration file for class "AppLesson" did not go to "app/Database/Migrations"'); + } } diff --git a/tests/system/Commands/CreateDatabaseTest.php b/tests/system/Commands/CreateDatabaseTest.php index aed4fd5c144e..a00de5c437a1 100644 --- a/tests/system/Commands/CreateDatabaseTest.php +++ b/tests/system/Commands/CreateDatabaseTest.php @@ -12,7 +12,8 @@ namespace CodeIgniter\Commands; use CodeIgniter\Database\BaseConnection; -use CodeIgniter\Database\SQLite3\Connection; +use CodeIgniter\Database\Database as DatabaseFactory; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Filters\CITestStreamFilter; use Config\Database; @@ -38,21 +39,24 @@ protected function setUp(): void $this->connection = Database::connect(); parent::setUp(); - } - protected function tearDown(): void - { - stream_filter_remove($this->streamFilter); - - if ($this->connection instanceof Connection) { + if ($this->connection instanceof SQLite3Connection) { $file = WRITEPATH . 'foobar.db'; - if (file_exists($file)) { unlink($file); } } else { - Database::forge()->dropDatabase('foobar'); + $util = (new DatabaseFactory())->loadUtils($this->connection); + + if ($util->databaseExists('foobar')) { + Database::forge()->dropDatabase('foobar'); + } } + } + + protected function tearDown(): void + { + stream_filter_remove($this->streamFilter); parent::tearDown(); } @@ -70,7 +74,7 @@ public function testCreateDatabase() public function testSqliteDatabaseDuplicated() { - if (! $this->connection instanceof Connection) { + if (! $this->connection instanceof SQLite3Connection) { $this->markTestSkipped('Needs to run on SQLite3.'); } @@ -83,7 +87,7 @@ public function testSqliteDatabaseDuplicated() public function testOtherDriverDuplicatedDatabase() { - if ($this->connection instanceof Connection) { + if ($this->connection instanceof SQLite3Connection) { $this->markTestSkipped('Needs to run on non-SQLite3 drivers.'); } diff --git a/tests/system/Commands/EnvironmentCommandTest.php b/tests/system/Commands/EnvironmentCommandTest.php index bd7f04ff3121..e81fe65ec622 100644 --- a/tests/system/Commands/EnvironmentCommandTest.php +++ b/tests/system/Commands/EnvironmentCommandTest.php @@ -20,9 +20,7 @@ final class EnvironmentCommandTest extends CIUnitTestCase { private $streamFilter; - - private $envPath = ROOTPATH . '.env'; - + private $envPath = ROOTPATH . '.env'; private $backupEnvPath = ROOTPATH . '.env.backup'; protected function setUp(): void diff --git a/tests/system/Commands/MigrationIntegrationTest.php b/tests/system/Commands/MigrationIntegrationTest.php index 77c047ef82ae..7dab2f1f8ef1 100644 --- a/tests/system/Commands/MigrationIntegrationTest.php +++ b/tests/system/Commands/MigrationIntegrationTest.php @@ -20,10 +20,8 @@ final class MigrationIntegrationTest extends CIUnitTestCase { private $streamFilter; - private $migrationFileFrom = SUPPORTPATH . 'Database/Migrations/20160428212500_Create_test_tables.php'; - - private $migrationFileTo = APPPATH . 'Database/Migrations/20160428212500_Create_test_tables.php'; + private $migrationFileTo = APPPATH . 'Database/Migrations/20160428212500_Create_test_tables.php'; protected function setUp(): void { @@ -60,9 +58,6 @@ protected function tearDown(): void stream_filter_remove($this->streamFilter); } - /** - * @runTestsInSeparateProcesses - */ public function testMigrationWithRollbackHasSameNameFormat(): void { command('migrate -n App'); diff --git a/tests/system/Commands/ModelGeneratorTest.php b/tests/system/Commands/ModelGeneratorTest.php index 68e001103fb5..2d00460c53ee 100644 --- a/tests/system/Commands/ModelGeneratorTest.php +++ b/tests/system/Commands/ModelGeneratorTest.php @@ -19,10 +19,11 @@ */ final class ModelGeneratorTest extends CIUnitTestCase { - protected $streamFilter; + private $streamFilter; protected function setUp(): void { + parent::setUp(); CITestStreamFilter::$buffer = ''; $this->streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); @@ -31,16 +32,18 @@ protected function setUp(): void protected function tearDown(): void { + parent::tearDown(); stream_filter_remove($this->streamFilter); $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', CITestStreamFilter::$buffer); $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); + if (is_file($file)) { unlink($file); } } - protected function getFileContent(string $filepath): string + private function getFileContent(string $filepath): string { if (! is_file($filepath)) { return ''; @@ -51,14 +54,14 @@ protected function getFileContent(string $filepath): string public function testGenerateModel() { - command('make:model user -table users'); + command('make:model user --table users'); $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); $this->assertStringContainsString('extends Model', $this->getFileContent($file)); - $this->assertStringContainsString('protected $table = \'users\';', $this->getFileContent($file)); - $this->assertStringContainsString('protected $DBGroup = \'default\';', $this->getFileContent($file)); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $table = \'users\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $DBGroup = \'default\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionTable() @@ -67,7 +70,7 @@ public function testGenerateModelWithOptionTable() $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $file = APPPATH . 'Models/Cars.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $table = \'utilisateur\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $table = \'utilisateur\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionDBGroup() @@ -76,43 +79,48 @@ public function testGenerateModelWithOptionDBGroup() $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $DBGroup = \'testing\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $DBGroup = \'testing\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnArray() { - command('make:model user -return array'); + command('make:model user --return array'); $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnObject() { - command('make:model user -return object'); + command('make:model user --return object'); $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'object\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'object\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnEntity() { - command('make:model user -return entity'); + command('make:model user --return entity'); $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); + $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'App\Entities\User\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \App\Entities\User::class;', $this->getFileContent($file)); + if (is_file($file)) { unlink($file); } + $file = APPPATH . 'Entities/User.php'; $this->assertFileExists($file); $dir = dirname($file); + if (is_file($file)) { unlink($file); } + if (is_dir($dir)) { rmdir($dir); } @@ -120,13 +128,32 @@ public function testGenerateModelWithOptionReturnEntity() public function testGenerateModelWithOptionSuffix() { - command('make:model user -suffix -return entity'); + command('make:model user --suffix --return entity'); $model = APPPATH . 'Models/UserModel.php'; $entity = APPPATH . 'Entities/UserEntity.php'; $this->assertFileExists($model); $this->assertFileExists($entity); + + unlink($model); + unlink($entity); + rmdir(dirname($entity)); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/5050 + */ + public function testGenerateModelWithSuffixAndMixedPascalCasedName() + { + command('make:model MyTable --suffix --return entity'); + + $model = APPPATH . 'Models/MyTableModel.php'; + $entity = APPPATH . 'Entities/MyTableEntity.php'; + + $this->assertFileExists($model); + $this->assertFileExists($entity); + unlink($model); unlink($entity); rmdir(dirname($entity)); diff --git a/tests/system/Commands/PublishCommandTest.php b/tests/system/Commands/PublishCommandTest.php new file mode 100644 index 000000000000..ea7b09e73511 --- /dev/null +++ b/tests/system/Commands/PublishCommandTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\Publishers\TestPublisher; + +/** + * @internal + */ +final class PublishCommandTest extends CIUnitTestCase +{ + private $streamFilter; + + protected function setUp(): void + { + parent::setUp(); + CITestStreamFilter::$buffer = ''; + + $this->streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); + $this->streamFilter = stream_filter_append(STDERR, 'CITestStreamFilter'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + stream_filter_remove($this->streamFilter); + TestPublisher::setResult(true); + } + + public function testDefault() + { + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishSuccess', [ + TestPublisher::class, + 0, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } + + public function testFailure() + { + TestPublisher::setResult(false); + + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishFailure', [ + TestPublisher::class, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } +} diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 1ce1e7ab61a2..ba4c1e7a4e07 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -22,15 +22,14 @@ use CodeIgniter\Session\Session; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockIncomingRequest; +use CodeIgniter\Test\Mock\MockSecurity; use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; use Config\App; use Config\Logger; use Config\Modules; use InvalidArgumentException; -use RuntimeException; use stdClass; -use Tests\Support\Autoloader\FatalLocator; use Tests\Support\Models\JobModel; /** @@ -232,6 +231,8 @@ public function testAppTimezone() public function testCSRFToken() { + Services::injectMock('security', new MockSecurity(new App())); + $this->assertSame('csrf_test_name', csrf_token()); } @@ -476,43 +477,6 @@ public function dirtyPathsProvider() ]; } - public function testHelperWithFatalLocatorThrowsException() - { - // Replace the locator with one that will fail if it is called - $locator = new FatalLocator(Services::autoloader()); - Services::injectMock('locator', $locator); - - try { - helper('baguette'); - $exception = false; - } catch (RuntimeException $e) { - $exception = true; - } - - $this->assertTrue($exception); - Services::reset(); - } - - public function testHelperLoadsOnce() - { - // Load it the first time - helper('baguette'); - - // Replace the locator with one that will fail if it is called - $locator = new FatalLocator(Services::autoloader()); - Services::injectMock('locator', $locator); - - try { - helper('baguette'); - $exception = false; - } catch (RuntimeException $e) { - $exception = true; - } - - $this->assertFalse($exception); - Services::reset(); - } - public function testIsCli() { $this->assertIsBool(is_cli()); diff --git a/tests/system/CommonHelperTest.php b/tests/system/CommonHelperTest.php new file mode 100644 index 000000000000..ce472e56b8be --- /dev/null +++ b/tests/system/CommonHelperTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Services; +use PHPUnit\Framework\MockObject\MockObject; +use RuntimeException; +use Tests\Support\Autoloader\FatalLocator; + +/** + * @internal + * + * @covers ::helper + */ +final class CommonHelperTest extends CIUnitTestCase +{ + private $dummyHelpers = [ + APPPATH . 'Helpers' . DIRECTORY_SEPARATOR . 'foobarbaz_helper.php', + SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR . 'foobarbaz_helper.php', + ]; + + protected function setUp(): void + { + $this->resetServices(); + parent::setUp(); + $this->cleanUpDummyHelpers(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->cleanUpDummyHelpers(); + $this->resetServices(); + } + + private function createDummyHelpers(): void + { + $text = <<<'PHP' + dummyHelpers as $helper) { + file_put_contents($helper, $text); + } + } + + private function cleanUpDummyHelpers(): void + { + foreach ($this->dummyHelpers as $helper) { + if (is_file($helper)) { + unlink($helper); + } + } + } + + /** + * @return FileLocator&MockObject + */ + private function getMockLocator() + { + return $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([Services::autoloader()]) + ->onlyMethods(['search']) + ->getMock(); + } + + public function testHelperWithFatalLocatorThrowsException() + { + // Replace the locator with one that will fail if it is called + $locator = new FatalLocator(Services::autoloader()); + Services::injectMock('locator', $locator); + + try { + helper('baguette'); + $exception = false; + } catch (RuntimeException $e) { + $exception = true; + } + + $this->assertTrue($exception); + } + + public function testHelperLoadsOnce() + { + // Load it the first time + helper('baguette'); + + // Replace the locator with one that will fail if it is called + $locator = new FatalLocator(Services::autoloader()); + Services::injectMock('locator', $locator); + + try { + helper('baguette'); + $exception = false; + } catch (RuntimeException $e) { + $exception = true; + } + + $this->assertFalse($exception); + } + + public function testHelperLoadsAppHelperFirst(): void + { + foreach ($this->dummyHelpers as $helper) { + $this->assertFileDoesNotExist($helper, sprintf( + 'The dummy helper file "%s" should not be existing before it is tested.', + $helper + )); + } + + $this->createDummyHelpers(); + $locator = $this->getMockLocator(); + $locator->method('search')->with('Helpers/foobarbaz_helper')->willReturn($this->dummyHelpers); + Services::injectMock('locator', $locator); + + helper('foobarbaz'); + + // this chunk is not needed really; just added so that IDEs will be happy + if (! function_exists('foo_bar_baz')) { + function foo_bar_baz(): string + { + return __FILE__; + } + } + + $this->assertSame($this->dummyHelpers[0], foo_bar_baz()); + } +} diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 1dca6f823ab8..bc2f4a1f60c8 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -11,8 +11,11 @@ namespace CodeIgniter; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Config\Services; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockSecurity; +use Config\App; use ReflectionClass; use ReflectionMethod; @@ -26,6 +29,8 @@ final class CommonSingleServiceTest extends CIUnitTestCase */ public function testSingleServiceWithNoParamsSupplied(string $service): void { + Services::injectMock('security', new MockSecurity(new App())); + $service1 = single_service($service); $service2 = single_service($service); @@ -38,6 +43,17 @@ public function testSingleServiceWithNoParamsSupplied(string $service): void */ public function testSingleServiceWithAtLeastOneParamSupplied(string $service): void { + if ($service === 'commands') { + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([Services::autoloader()]) + ->onlyMethods(['listFiles']) + ->getMock(); + + // `Commands::discoverCommand()` is an expensive operation + $locator->method('listFiles')->with('Commands/')->willReturn([]); + Services::injectMock('locator', $locator); + } + $params = []; $method = new ReflectionMethod(Services::class, $service); @@ -48,6 +64,10 @@ public function testSingleServiceWithAtLeastOneParamSupplied(string $service): v $this->assertSame(get_class($service1), get_class($service2)); $this->assertNotSame($service1, $service2); + + if ($service === 'commands') { + $this->resetServices(); + } } public function testSingleServiceWithAllParamsSupplied(): void @@ -72,25 +92,33 @@ public function testSingleServiceWithGibberishGiven(): void public static function serviceNamesProvider(): iterable { - $methods = (new ReflectionClass(Services::class))->getMethods(ReflectionMethod::IS_PUBLIC); - - foreach ($methods as $method) { - $name = $method->getName(); - $excl = [ - '__callStatic', - 'serviceExists', - 'reset', - 'resetSingle', - 'injectMock', - 'encrypter', // Encrypter needs a starter key - 'session', // Headers already sent - ]; - - if (in_array($name, $excl, true)) { - continue; + static $services = []; + static $excl = [ + '__callStatic', + 'serviceExists', + 'reset', + 'resetSingle', + 'injectMock', + 'encrypter', // Encrypter needs a starter key + 'session', // Headers already sent + ]; + + if ($services === []) { + $methods = (new ReflectionClass(Services::class))->getMethods(ReflectionMethod::IS_PUBLIC); + + foreach ($methods as $method) { + $name = $method->getName(); + + if (in_array($name, $excl, true)) { + continue; + } + + $services[$name] = [$name]; } - yield [$name]; + ksort($services); } + + yield from $services; } } diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 872e7ce48b2f..4c2d4ba20ccb 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -94,6 +94,10 @@ public function testEnvironmentOverrides() $this->assertSame('banana', $config->dessert); // null property should not be affected $this->assertNull($config->QEMPTYSTR); + // property name with underscore + $this->assertSame('bar', $config->onedeep_value); + // array property name with underscore and key with underscore + $this->assertSame('foo', $config->one_deep['under_deep']); } public function testPrefixedValues() diff --git a/tests/system/Config/DotEnvTest.php b/tests/system/Config/DotEnvTest.php index dbdb1c1c6743..2aff090b9576 100644 --- a/tests/system/Config/DotEnvTest.php +++ b/tests/system/Config/DotEnvTest.php @@ -158,7 +158,7 @@ public function testNamespacedVariables() $dotenv = new DotEnv($this->fixturesFolder, '.env'); $dotenv->load(); - $this->assertSame('complex', $_SERVER['SimpleConfig.simple.name']); + $this->assertSame('complex', $_SERVER['SimpleConfig_simple_name']); } public function testLoadsGetServerVar() diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 6733ee06fff6..77a0c77097fc 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -64,7 +64,7 @@ protected function setUp(): void protected function tearDown(): void { $_SERVER = $this->original; - Services::reset(); + $this->resetServices(); } public function testCanReplaceFrameworkServices() @@ -397,6 +397,8 @@ public function testRouter() public function testSecurity() { + Services::injectMock('security', new MockSecurity(new App())); + $result = Services::security(); $this->assertInstanceOf(Security::class, $result); } diff --git a/tests/system/Config/fixtures/.env b/tests/system/Config/fixtures/.env index e8fa09bd40ed..2f859cc9faca 100644 --- a/tests/system/Config/fixtures/.env +++ b/tests/system/Config/fixtures/.env @@ -6,15 +6,22 @@ NULL= SimpleConfig.onedeep=baz SimpleConfig.default.name=ci4 -SimpleConfig.simple.name=complex +# Use underscore as separator +SimpleConfig_simple_name=complex -# for environment override testing +## for environment override testing SimpleConfig.alpha=pow SimpleConfig.charliewrong=null -SimpleConfig.notthere=missing +# Use underscore as separator +SimpleConfig_notthere=missing simpleconfig.delta=hubbahubba simpleconfig.foxtrot="true" -simpleconfig.dessert="banana" +# Use underscore as separator +simpleconfig_dessert="banana" +# Properity name with undersocre +SimpleConfig.onedeep_value=bar +# Array properity name with undersocre and key name with undersocre +SimpleConfig.one_deep.under_deep=foo bravo=kazaam diff --git a/tests/system/Config/fixtures/Encryption.php b/tests/system/Config/fixtures/Encryption.php index 2793795f56ff..7a8a00bc03fe 100644 --- a/tests/system/Config/fixtures/Encryption.php +++ b/tests/system/Config/fixtures/Encryption.php @@ -15,6 +15,7 @@ class Encryption extends EncryptionConfig { private const HEX2BIN = 'hex2bin:84cf2c0811d5daf9e1c897825a3debce91f9a33391e639f72f7a4740b30675a2'; private const BASE64 = 'base64:Psf8bUHRh1UJYG2M7e+5ec3MdjpKpzAr0twamcAvOcI='; + public $key; public $driver = 'MCrypt'; diff --git a/tests/system/Config/fixtures/SimpleConfig.php b/tests/system/Config/fixtures/SimpleConfig.php index d2e13fbda3c0..69f5590e90f1 100644 --- a/tests/system/Config/fixtures/SimpleConfig.php +++ b/tests/system/Config/fixtures/SimpleConfig.php @@ -25,7 +25,8 @@ class SimpleConfig extends \CodeIgniter\Config\BaseConfig public $simple = [ 'name' => null, ]; - // properties for environment over-ride testing + + // properties for environment override testing public $alpha = 'one'; public $bravo = 'two'; public $charlie = 'three'; @@ -41,7 +42,10 @@ class SimpleConfig extends \CodeIgniter\Config\BaseConfig 'doctor' => 'Bones', 'comms' => 'Uhuru', ]; - public $shortie; public $longie; + public $onedeep_value; + public $one_deep = [ + 'under_deep' => null, + ]; } diff --git a/tests/system/ControllerTest.php b/tests/system/ControllerTest.php index 3d7c205eec0b..d4be0c503b7a 100644 --- a/tests/system/ControllerTest.php +++ b/tests/system/ControllerTest.php @@ -56,6 +56,7 @@ final class ControllerTest extends CIUnitTestCase * @var Response */ protected $response; + /** * @var LoggerInterface */ diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 85126836650f..7d2352a0e9b2 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -39,7 +39,6 @@ final class BaseConnectionTest extends CIUnitTestCase 'strictOn' => true, 'failover' => [], ]; - protected $failoverOptions = [ 'DSN' => '', 'hostname' => 'localhost', diff --git a/tests/system/Database/BaseQueryTest.php b/tests/system/Database/BaseQueryTest.php index f53beadcd8e9..fe942ffeb187 100644 --- a/tests/system/Database/BaseQueryTest.php +++ b/tests/system/Database/BaseQueryTest.php @@ -238,6 +238,23 @@ public function testBindingAutoEscapesParameters() $this->assertSame($expected, $query->getQuery()); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/5114 + */ + public function testBindingWithTwoColons() + { + $query = new Query($this->db); + + $query->setQuery( + "SELECT mytable.id, DATE_FORMAT(mytable.created_at,'%d/%m/%Y %H:%i:%s') AS created_at_uk FROM mytable WHERE mytable.id = ?", + [1] + ); + + $expected = "SELECT mytable.id, DATE_FORMAT(mytable.created_at,'%d/%m/%Y %H:%i:%s') AS created_at_uk FROM mytable WHERE mytable.id = 1"; + + $this->assertSame($expected, $query->getQuery()); + } + public function testNamedBinds() { $query = new Query($this->db); @@ -277,6 +294,20 @@ public function testSimilarNamedBinds() $this->assertSame($expected, $query->getQuery()); } + public function testNamedBindsDontGetReplacedAgain() + { + $query = new Query($this->db); + + $query->setQuery( + 'SELECT * FROM posts WHERE content = :content: OR foobar = :foobar:', + ['content' => 'a placeholder looks like :foobar:', 'foobar' => 'bazqux'] + ); + + $expected = "SELECT * FROM posts WHERE content = 'a placeholder looks like :foobar:' OR foobar = 'bazqux'"; + + $this->assertSame($expected, $query->getQuery()); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/1705 */ @@ -332,4 +363,37 @@ public function testSetQueryBinds() $this->assertSame($expected, $query->getQuery()); } + + public function queryKeywords() + { + return [ + 'highlightKeyWords' => [ + 'SELECT `a`.*, `b`.`id` AS `b_id` FROM `a` LEFT JOIN `b` ON `b`.`a_id` = `a`.`id` WHERE `b`.`id` IN ('1') AND `a`.`deleted_at` IS NOT NULL LIMIT 1', + 'SELECT `a`.*, `b`.`id` AS `b_id` FROM `a` LEFT JOIN `b` ON `b`.`a_id` = `a`.`id` WHERE `b`.`id` IN (\'1\') AND `a`.`deleted_at` IS NOT NULL LIMIT 1', + ], + 'ignoreKeyWordsInValues' => [ + 'SELECT * FROM `a` WHERE `a`.`col` = 'SELECT escaped keyword in value' LIMIT 1', + 'SELECT * FROM `a` WHERE `a`.`col` = \'SELECT escaped keyword in value\' LIMIT 1', + ], + 'escapeHtmlValues' => [ + 'SELECT '<s>' FROM dual', + 'SELECT \'\' FROM dual', + ], + ]; + } + + /** + * @dataProvider queryKeywords + * + * @param mixed $expected + * @param mixed $sql + */ + public function testHighlightQueryKeywords($expected, $sql) + { + $query = new Query($this->db); + $query->setQuery($sql); + $query->getQuery(); + + $this->assertSame($expected, $query->debugToolbarDisplay()); + } } diff --git a/tests/system/Database/Builder/InsertTest.php b/tests/system/Database/Builder/InsertTest.php index f2537543ac5e..5235a3bdbf58 100644 --- a/tests/system/Database/Builder/InsertTest.php +++ b/tests/system/Database/Builder/InsertTest.php @@ -91,13 +91,42 @@ public function testInsertBatch() $query = $this->db->getLastQuery(); $this->assertInstanceOf(Query::class, $query); - $raw = 'INSERT INTO "jobs" ("description", "id", "name") VALUES (:description:,:id:,:name:), (:description.1:,:id.1:,:name.1:)'; + $raw = <<<'SQL' + INSERT INTO "jobs" ("description", "id", "name") VALUES ('There''s something in your teeth',2,'Commedian'), ('I am yellow',3,'Cab Driver') + SQL; $this->assertSame($raw, str_replace("\n", ' ', $query->getOriginalQuery())); $expected = "INSERT INTO \"jobs\" (\"description\", \"id\", \"name\") VALUES ('There''s something in your teeth',2,'Commedian'), ('I am yellow',3,'Cab Driver')"; $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); } + public function testInsertBatchWithoutEscape() + { + $builder = $this->db->table('jobs'); + + $insertData = [ + [ + 'id' => 2, + 'name' => '1 + 1', + 'description' => '1 + 2', + ], + [ + 'id' => 3, + 'name' => '2 + 1', + 'description' => '2 + 2', + ], + ]; + + $this->db->shouldReturn('execute', 1)->shouldReturn('affectedRows', 1); + $builder->insertBatch($insertData, false); + + $query = $this->db->getLastQuery(); + $this->assertInstanceOf(Query::class, $query); + + $expected = 'INSERT INTO "jobs" ("description", "id", "name") VALUES (1 + 2,2,1 + 1), (2 + 2,3,2 + 1)'; + $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/4345 */ @@ -118,9 +147,6 @@ public function testInsertBatchWithFieldsEndingInNumbers() $query = $this->db->getLastQuery(); $this->assertInstanceOf(Query::class, $query); - $raw = 'INSERT INTO "ip_table" ("ip", "ip2") VALUES (:ip:,:ip2:), (:ip.1:,:ip2.1:), (:ip.2:,:ip2.2:), (:ip.3:,:ip2.3:)'; - $this->assertSame($raw, str_replace("\n", ' ', $query->getOriginalQuery())); - $expected = "INSERT INTO \"ip_table\" (\"ip\", \"ip2\") VALUES ('1.1.1.0','1.1.1.2'), ('2.2.2.0','2.2.2.2'), ('3.3.3.0','3.3.3.2'), ('4.4.4.0','4.4.4.2')"; $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); } diff --git a/tests/system/Database/Builder/LikeTest.php b/tests/system/Database/Builder/LikeTest.php index 74a2d2fbbf81..ba7c176b7c90 100644 --- a/tests/system/Database/Builder/LikeTest.php +++ b/tests/system/Database/Builder/LikeTest.php @@ -172,7 +172,7 @@ public function testCaseInsensitiveLike() $builder->like('name', 'VELOPER', 'both', null, true); - $expectedSQL = "SELECT * FROM \"job\" WHERE LOWER(name) LIKE '%veloper%' ESCAPE '!'"; + $expectedSQL = "SELECT * FROM \"job\" WHERE LOWER(\"name\") LIKE '%veloper%' ESCAPE '!'"; $expectedBinds = [ 'name' => [ '%veloper%', diff --git a/tests/system/Database/Builder/OrderTest.php b/tests/system/Database/Builder/OrderTest.php index 3b3b6622c59f..64649822eb6d 100644 --- a/tests/system/Database/Builder/OrderTest.php +++ b/tests/system/Database/Builder/OrderTest.php @@ -61,4 +61,17 @@ public function testOrderRandom() $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); } + + public function testOrderRandomWithRandomColumn() + { + $this->db->setPrefix('fail_'); + $builder = new BaseBuilder('user', $this->db); + $this->setPrivateProperty($builder, 'randomKeyword', ['"SYSTEM"."RANDOM"']); + + $builder->orderBy('name', 'random'); + + $expectedSQL = 'SELECT * FROM "fail_user" ORDER BY "SYSTEM"."RANDOM"'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } } diff --git a/tests/system/Database/Builder/UpdateTest.php b/tests/system/Database/Builder/UpdateTest.php index 801324d01f79..bda9ae49915d 100644 --- a/tests/system/Database/Builder/UpdateTest.php +++ b/tests/system/Database/Builder/UpdateTest.php @@ -99,6 +99,80 @@ public function testUpdateWithSet() $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testUpdateWithSetAsInt() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set('age', 22)->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "age" = 22 WHERE "id" = 1'; + $expectedBinds = [ + 'age' => [ + 22, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testUpdateWithSetAsBoolean() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set('manager', true)->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "manager" = 1 WHERE "id" = 1'; + $expectedBinds = [ + 'manager' => [ + true, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testUpdateWithSetAsArray() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set(['name' => 'Programmer', 'age' => 22, 'manager' => true])->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "name" = \'Programmer\', "age" = 22, "manager" = 1 WHERE "id" = 1'; + $expectedBinds = [ + 'name' => [ + 'Programmer', + true, + ], + 'age' => [ + 22, + true, + ], + 'manager' => [ + true, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + public function testUpdateThrowsExceptionWithNoData() { $builder = new BaseBuilder('jobs', $this->db); @@ -136,24 +210,51 @@ public function testUpdateBatch() $expected = <<assertSame($expected, $query->getOriginalQuery()); + $this->assertSame($expected, $query->getQuery()); + } + + public function testSetUpdateBatchWithoutEscape() + { + $builder = new BaseBuilder('jobs', $this->db); + $escape = false; + + $builder->setUpdateBatch([ + [ + 'id' => 2, + 'name' => 'SUBSTRING(name, 1)', + 'description' => 'SUBSTRING(description, 3)', + ], + [ + 'id' => 3, + 'name' => 'SUBSTRING(name, 2)', + 'description' => 'SUBSTRING(description, 4)', + ], + ], 'id', $escape); + + $this->db->shouldReturn('execute', 1)->shouldReturn('affectedRows', 1); + $builder->updateBatch(null, 'id'); + + $query = $this->db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $space = ' '; $expected = <<where('id', 2) ->update(null, null, null); - $expectedSQL = 'UPDATE "mytable" SET field = field+1 WHERE "id" = 2'; + $expectedSQL = 'UPDATE "mytable" SET "field" = field+1 WHERE "id" = 2'; $expectedBinds = [ 'id' => [ 2, @@ -298,7 +399,7 @@ public function testSetWithAndWithoutEscape() ->where('id', 2) ->update(null, null, null); - $expectedSQL = 'UPDATE "mytable" SET "foo" = \'bar\', field = field+1 WHERE "id" = 2'; + $expectedSQL = 'UPDATE "mytable" SET "foo" = \'bar\', "field" = field+1 WHERE "id" = 2'; $expectedBinds = [ 'foo' => [ 'bar', diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index d4f795dcc26b..0dc9a1619485 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -40,7 +40,6 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 3306, ]; - protected $dsnGroup = [ 'DSN' => 'MySQLi://user:pass@localhost:3306/dbname?DBPrefix=test_&pConnect=true&charset=latin1&DBCollat=latin1_swedish_ci', 'hostname' => '', @@ -60,7 +59,6 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 3306, ]; - protected $dsnGroupPostgre = [ 'DSN' => 'Postgre://user:pass@localhost:5432/dbname?DBPrefix=test_&connect_timeout=5&sslmode=1', 'hostname' => '', @@ -80,7 +78,6 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 5432, ]; - protected $dsnGroupPostgreNative = [ 'DSN' => 'pgsql:host=localhost;port=5432;dbname=database_name', 'hostname' => '', diff --git a/tests/system/Database/Forge/CreateTableTest.php b/tests/system/Database/Forge/CreateTableTest.php new file mode 100644 index 000000000000..1a1f521c3cbb --- /dev/null +++ b/tests/system/Database/Forge/CreateTableTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Forge; + +use CodeIgniter\Database\Forge; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; + +/** + * @internal + */ +final class CreateTableTest extends CIUnitTestCase +{ + public function testCreateTableWithExists() + { + $dbMock = $this->getMockBuilder(MockConnection::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['listTables']) + ->getMock(); + $dbMock->expects($this->any()) + ->method('listTables') + ->willReturn(['foo']); + + $forge = new class ($dbMock) extends Forge { + protected $createTableIfStr = false; + }; + + $forge->addField('id'); + $actual = $forge->createTable('foo', true); + + $this->assertTrue($actual); + } +} diff --git a/tests/system/Database/Forge/DropForeignKeyTest.php b/tests/system/Database/Forge/DropForeignKeyTest.php new file mode 100644 index 000000000000..96c2d2790425 --- /dev/null +++ b/tests/system/Database/Forge/DropForeignKeyTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Forge; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Forge; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; + +/** + * @internal + */ +final class DropForeignKeyTest extends CIUnitTestCase +{ + protected $db; + + protected function setUp(): void + { + parent::setUp(); + $this->db = new MockConnection([]); + } + + public function testDropForeignKeyWithEmptyDropConstraintStrProperty() + { + $this->setPrivateProperty($this->db, 'DBDebug', true); + + $forge = new Forge($this->db); + + $this->expectException(DatabaseException::class); + + $forge->dropForeignKey('id', 'fail'); + } +} diff --git a/tests/system/Database/Live/AliasTest.php b/tests/system/Database/Live/AliasTest.php index 0024625ce5b5..c032cd1b6546 100644 --- a/tests/system/Database/Live/AliasTest.php +++ b/tests/system/Database/Live/AliasTest.php @@ -24,8 +24,7 @@ final class AliasTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testAlias() { diff --git a/tests/system/Database/Live/BadQueryTest.php b/tests/system/Database/Live/BadQueryTest.php index 44a2adb0a391..b7d7840525c6 100644 --- a/tests/system/Database/Live/BadQueryTest.php +++ b/tests/system/Database/Live/BadQueryTest.php @@ -25,8 +25,7 @@ final class BadQueryTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; protected static $origDebug; /** diff --git a/tests/system/Database/Live/ConnectTest.php b/tests/system/Database/Live/ConnectTest.php index 680f03c94ca7..5ee18a6f25eb 100644 --- a/tests/system/Database/Live/ConnectTest.php +++ b/tests/system/Database/Live/ConnectTest.php @@ -27,9 +27,7 @@ final class ConnectTest extends CIUnitTestCase use DatabaseTestTrait; protected $group1; - protected $group2; - protected $tests; protected function setUp(): void diff --git a/tests/system/Database/Live/CountTest.php b/tests/system/Database/Live/CountTest.php index bfe917cb2adc..d87833759f24 100644 --- a/tests/system/Database/Live/CountTest.php +++ b/tests/system/Database/Live/CountTest.php @@ -24,8 +24,7 @@ final class CountTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testCountReturnsZeroWithNoResults() { diff --git a/tests/system/Database/Live/DatabaseTestTraitCaseTest.php b/tests/system/Database/Live/DatabaseTestTraitCaseTest.php index 8e917a7d30e9..3e9267b2b05d 100644 --- a/tests/system/Database/Live/DatabaseTestTraitCaseTest.php +++ b/tests/system/Database/Live/DatabaseTestTraitCaseTest.php @@ -24,8 +24,7 @@ final class DatabaseTestTraitCaseTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testHasInDatabase() { diff --git a/tests/system/Database/Live/DeleteTest.php b/tests/system/Database/Live/DeleteTest.php index bd3cd7d6d05c..31b89412e78a 100644 --- a/tests/system/Database/Live/DeleteTest.php +++ b/tests/system/Database/Live/DeleteTest.php @@ -25,8 +25,7 @@ final class DeleteTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testDeleteThrowExceptionWithNoCriteria() { diff --git a/tests/system/Database/Live/EmptyTest.php b/tests/system/Database/Live/EmptyTest.php index 5dfcec87e418..912f627911c6 100644 --- a/tests/system/Database/Live/EmptyTest.php +++ b/tests/system/Database/Live/EmptyTest.php @@ -24,8 +24,7 @@ final class EmptyTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testEmpty() { diff --git a/tests/system/Database/Live/EscapeTest.php b/tests/system/Database/Live/EscapeTest.php index f5c3ff8b59c1..08265788d00e 100644 --- a/tests/system/Database/Live/EscapeTest.php +++ b/tests/system/Database/Live/EscapeTest.php @@ -24,7 +24,6 @@ final class EscapeTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = false; - protected $char; protected function setUp(): void diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index 69812f98444b..08ca65e03b95 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -29,8 +29,7 @@ final class ForgeTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; /** * @var Forge @@ -372,6 +371,8 @@ public function testDropTableWithEmptyName() public function testForeignKey() { + $this->forge->dropTable('forge_test_users', true); + $attributes = []; if ($this->db->DBDriver === 'MySQLi') { @@ -427,6 +428,172 @@ public function testForeignKey() $this->forge->dropTable('forge_test_users', true); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4986 + */ + public function testForeignKeyAddingWithStringFields() + { + if ($this->db->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Testing only on MySQLi but fix expands to all DBs.'); + } + + $attributes = ['ENGINE' => 'InnoDB']; + + $this->forge->addField([ + '`id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', + '`name` VARCHAR(255) NOT NULL', + ])->createTable('forge_test_users', true, $attributes); + + $this->forge + ->addField([ + '`id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', + '`users_id` INT(11) NOT NULL', + '`name` VARCHAR(255) NOT NULL', + ]) + ->addForeignKey('users_id', 'forge_test_users', 'id', 'CASCADE', 'CASCADE') + ->createTable('forge_test_invoices', true, $attributes); + + $foreignKeyData = $this->db->getForeignKeyData('forge_test_invoices')[0]; + + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices_users_id_foreign', $foreignKeyData->constraint_name); + $this->assertSame('users_id', $foreignKeyData->column_name); + $this->assertSame('id', $foreignKeyData->foreign_column_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices', $foreignKeyData->table_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_users', $foreignKeyData->foreign_table_name); + + $this->forge->dropTable('forge_test_invoices', true); + $this->forge->dropTable('forge_test_users', true); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4310 + */ + public function testCompositeForeignKey() + { + $attributes = []; + + if ($this->db->DBDriver === 'MySQLi') { + $attributes = ['ENGINE' => 'InnoDB']; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'second_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + ]); + $this->forge->addPrimaryKey(['id', 'second_id']); + $this->forge->createTable('forge_test_users', true, $attributes); + + $this->forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'users_id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'users_second_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addForeignKey(['users_id', 'users_second_id'], 'forge_test_users', ['id', 'second_id'], 'CASCADE', 'CASCADE'); + + $this->forge->createTable('forge_test_invoices', true, $attributes); + + $foreignKeyData = $this->db->getForeignKeyData('forge_test_invoices'); + + if ($this->db->DBDriver === 'SQLite3') { + $this->assertSame('users_id to db_forge_test_users.id', $foreignKeyData[0]->constraint_name); + $this->assertSame(0, $foreignKeyData[0]->sequence); + $this->assertSame('users_second_id to db_forge_test_users.second_id', $foreignKeyData[1]->constraint_name); + $this->assertSame(1, $foreignKeyData[1]->sequence); + } else { + $haystack = ['users_id', 'users_second_id']; + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices_users_id_users_second_id_foreign', $foreignKeyData[0]->constraint_name); + $this->assertContains($foreignKeyData[0]->column_name, $haystack); + + $secondIdKey = $this->db->DBDriver === 'Postgre' ? 2 : 1; + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices_users_id_users_second_id_foreign', $foreignKeyData[$secondIdKey]->constraint_name); + $this->assertContains($foreignKeyData[$secondIdKey]->column_name, $haystack); + } + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices', $foreignKeyData[0]->table_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_users', $foreignKeyData[0]->foreign_table_name); + + $this->forge->dropTable('forge_test_invoices', true); + $this->forge->dropTable('forge_test_users', true); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4310 + */ + public function testCompositeForeignKeyFieldNotExistException() + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Field `user_id, user_second_id` not found.'); + + $attributes = []; + + if ($this->db->DBDriver === 'MySQLi') { + $attributes = ['ENGINE' => 'InnoDB']; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'second_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + ]); + $this->forge->addPrimaryKey(['id', 'second_id']); + $this->forge->createTable('forge_test_users', true, $attributes); + + $this->forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'users_id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'users_second_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey(['user_id', 'user_second_id'], 'forge_test_users', ['id', 'second_id'], 'CASCADE', 'CASCADE'); + + $this->forge->createTable('forge_test_invoices', true, $attributes); + } + public function testForeignKeyFieldNotExistException() { $this->expectException(DatabaseException::class); @@ -473,6 +640,8 @@ public function testForeignKeyFieldNotExistException() public function testDropForeignKey() { + $this->forge->dropTable('forge_test_users', true); + $attributes = []; if ($this->db->DBDriver === 'MySQLi') { @@ -898,4 +1067,38 @@ public function testDropMultipleColumnWithString() $this->forge->dropTable('forge_test_four', true); } + + public function testDropKey() + { + $this->forge->dropTable('key_test_users', true); + $keyName = 'key_test_users_id'; + + $attributes = []; + + if ($this->db->DBDriver === 'MySQLi') { + $keyName = 'id'; + $attributes = ['ENGINE' => 'InnoDB']; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INTEGER', + 'constraint' => 11, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + ]); + $this->forge->addKey('id'); + $this->forge->createTable('key_test_users', true, $attributes); + + $this->forge->dropKey('key_test_users', $keyName); + + $foreignKeyData = $this->db->getIndexData('key_test_users'); + + $this->assertEmpty($foreignKeyData); + + $this->forge->dropTable('key_test_users', true); + } } diff --git a/tests/system/Database/Live/FromTest.php b/tests/system/Database/Live/FromTest.php index 6a17f800e55a..5037dd717f71 100644 --- a/tests/system/Database/Live/FromTest.php +++ b/tests/system/Database/Live/FromTest.php @@ -24,8 +24,7 @@ final class FromTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testFromCanAddTables() { diff --git a/tests/system/Database/Live/GetNumRowsTest.php b/tests/system/Database/Live/GetNumRowsTest.php index bfa982d5d6a5..791fef5acf39 100644 --- a/tests/system/Database/Live/GetNumRowsTest.php +++ b/tests/system/Database/Live/GetNumRowsTest.php @@ -22,8 +22,7 @@ final class GetNumRowsTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; /** * Added as instructed at https://codeigniter4.github.io/userguide/testing/database.html#the-test-class diff --git a/tests/system/Database/Live/GetTest.php b/tests/system/Database/Live/GetTest.php index a2f1aa4f9b89..2682f12e57ca 100644 --- a/tests/system/Database/Live/GetTest.php +++ b/tests/system/Database/Live/GetTest.php @@ -25,8 +25,7 @@ final class GetTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testGet() { diff --git a/tests/system/Database/Live/GroupTest.php b/tests/system/Database/Live/GroupTest.php index 9ef9da758fff..705172ac97f0 100644 --- a/tests/system/Database/Live/GroupTest.php +++ b/tests/system/Database/Live/GroupTest.php @@ -24,8 +24,7 @@ final class GroupTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testGroupBy() { diff --git a/tests/system/Database/Live/IncrementTest.php b/tests/system/Database/Live/IncrementTest.php index d53732464f56..bb5faccf4e15 100644 --- a/tests/system/Database/Live/IncrementTest.php +++ b/tests/system/Database/Live/IncrementTest.php @@ -24,8 +24,7 @@ final class IncrementTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testIncrement() { diff --git a/tests/system/Database/Live/InsertTest.php b/tests/system/Database/Live/InsertTest.php index 8ca40e2309ef..a5bd090b7578 100644 --- a/tests/system/Database/Live/InsertTest.php +++ b/tests/system/Database/Live/InsertTest.php @@ -24,8 +24,7 @@ final class InsertTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testInsert() { diff --git a/tests/system/Database/Live/JoinTest.php b/tests/system/Database/Live/JoinTest.php index 7e35effcc852..b30c8ac8b9bb 100644 --- a/tests/system/Database/Live/JoinTest.php +++ b/tests/system/Database/Live/JoinTest.php @@ -24,8 +24,7 @@ final class JoinTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testSimpleJoin() { diff --git a/tests/system/Database/Live/LikeTest.php b/tests/system/Database/Live/LikeTest.php index 999539e488fc..65261f2372fb 100644 --- a/tests/system/Database/Live/LikeTest.php +++ b/tests/system/Database/Live/LikeTest.php @@ -24,8 +24,7 @@ final class LikeTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testLikeDefault() { diff --git a/tests/system/Database/Live/LimitTest.php b/tests/system/Database/Live/LimitTest.php index ee3f8c475b79..8673b5bc486d 100644 --- a/tests/system/Database/Live/LimitTest.php +++ b/tests/system/Database/Live/LimitTest.php @@ -22,9 +22,9 @@ final class LimitTest extends CIUnitTestCase { use DatabaseTestTrait; - protected $refresh = true; - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $refresh = true; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testLimit() { diff --git a/tests/system/Database/Live/MetadataTest.php b/tests/system/Database/Live/MetadataTest.php index e59b28f7b724..8c6806c4d86e 100644 --- a/tests/system/Database/Live/MetadataTest.php +++ b/tests/system/Database/Live/MetadataTest.php @@ -52,6 +52,10 @@ protected function setUp(): void $prefix . 'without_auto_increment', $prefix . 'ip_table', ]; + + if (in_array($this->db->DBDriver, ['MySQLi', 'Postgre'], true)) { + $this->expectedTables[] = $prefix . 'ci_sessions'; + } } public function testListTables() diff --git a/tests/system/Database/Live/OrderTest.php b/tests/system/Database/Live/OrderTest.php index 9d87089b5e79..76f7e8374f32 100644 --- a/tests/system/Database/Live/OrderTest.php +++ b/tests/system/Database/Live/OrderTest.php @@ -24,8 +24,7 @@ final class OrderTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testOrderAscending() { diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index b71c0498f80e..854cab85e7df 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\Query; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; +use Tests\Support\Database\Seeds\CITestSeeder; /** * @group DatabaseLive @@ -25,19 +26,38 @@ final class PreparedQueryTest extends CIUnitTestCase { use DatabaseTestTrait; - protected $refresh = true; - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = CITestSeeder::class; + + /** + * @var BasePreparedQuery|null + */ + private $query; + + protected function setUp(): void + { + parent::setUp(); + $this->query = null; + } + + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->query !== null) { + $this->query->close(); + } + } public function testPrepareReturnsPreparedQuery() { - $query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db) { return $db->table('user')->insert([ 'name' => 'a', 'email' => 'b@example.com', ]); }); - $this->assertInstanceOf(BasePreparedQuery::class, $query); + $this->assertInstanceOf(BasePreparedQuery::class, $this->query); $ec = $this->db->escapeChar; $pre = $this->db->DBPrefix; @@ -50,24 +70,23 @@ public function testPrepareReturnsPreparedQuery() if ($this->db->DBDriver === 'SQLSRV') { $database = $this->db->getDatabase(); - $expected = "INSERT INTO {$ec}{$database}{$ec}.{$ec}dbo{$ec}.{$ec}{$pre}user{$ec} ({$ec}name{$ec},{$ec}email{$ec}) VALUES ({$placeholders})"; + $expected = "INSERT INTO {$ec}{$database}{$ec}.{$ec}{$this->db->schema}{$ec}.{$ec}{$pre}user{$ec} ({$ec}name{$ec},{$ec}email{$ec}) VALUES ({$placeholders})"; } else { $expected = "INSERT INTO {$ec}{$pre}user{$ec} ({$ec}name{$ec}, {$ec}email{$ec}) VALUES ({$placeholders})"; } - $this->assertSame($expected, $query->getQueryString()); - $query->close(); + $this->assertSame($expected, $this->query->getQueryString()); } public function testPrepareReturnsManualPreparedQuery() { - $query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db) { $sql = "INSERT INTO {$db->DBPrefix}user (name, email, country) VALUES (?, ?, ?)"; return (new Query($db))->setQuery($sql); }); - $this->assertInstanceOf(BasePreparedQuery::class, $query); + $this->assertInstanceOf(BasePreparedQuery::class, $this->query); $pre = $this->db->DBPrefix; @@ -78,14 +97,12 @@ public function testPrepareReturnsManualPreparedQuery() } $expected = "INSERT INTO {$pre}user (name, email, country) VALUES ({$placeholders})"; - $this->assertSame($expected, $query->getQueryString()); - - $query->close(); + $this->assertSame($expected, $this->query->getQueryString()); } public function testExecuteRunsQueryAndReturnsResultObject() { - $query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db) { return $db->table('user')->insert([ 'name' => 'a', 'email' => 'b@example.com', @@ -93,29 +110,29 @@ public function testExecuteRunsQueryAndReturnsResultObject() ]); }); - $query->execute('foo', 'foo@example.com', 'US'); - $query->execute('bar', 'bar@example.com', 'GB'); + $this->query->execute('foo', 'foo@example.com', 'US'); + $this->query->execute('bar', 'bar@example.com', 'GB'); $this->seeInDatabase($this->db->DBPrefix . 'user', ['name' => 'foo', 'email' => 'foo@example.com']); $this->seeInDatabase($this->db->DBPrefix . 'user', ['name' => 'bar', 'email' => 'bar@example.com']); - - $query->close(); } public function testExecuteRunsQueryAndReturnsManualResultObject() { - $query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db) { $sql = "INSERT INTO {$db->DBPrefix}user (name, email, country) VALUES (?, ?, ?)"; + if ($db->DBDriver === 'SQLSRV') { + $sql = "INSERT INTO {$db->schema}.{$db->DBPrefix}user (name, email, country) VALUES (?, ?, ?)"; + } + return (new Query($db))->setQuery($sql); }); - $query->execute('foo', 'foo@example.com', ''); - $query->execute('bar', 'bar@example.com', ''); + $this->query->execute('foo', 'foo@example.com', ''); + $this->query->execute('bar', 'bar@example.com', ''); $this->seeInDatabase($this->db->DBPrefix . 'user', ['name' => 'foo', 'email' => 'foo@example.com']); $this->seeInDatabase($this->db->DBPrefix . 'user', ['name' => 'bar', 'email' => 'bar@example.com']); - - $query->close(); } } diff --git a/tests/system/Database/Live/SelectTest.php b/tests/system/Database/Live/SelectTest.php index 6224633847d6..fbfa3ca97052 100644 --- a/tests/system/Database/Live/SelectTest.php +++ b/tests/system/Database/Live/SelectTest.php @@ -24,8 +24,7 @@ final class SelectTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testSelectAllByDefault() { diff --git a/tests/system/Database/Live/UpdateTest.php b/tests/system/Database/Live/UpdateTest.php index f047f16a7bfd..f7e2fb5e4b32 100644 --- a/tests/system/Database/Live/UpdateTest.php +++ b/tests/system/Database/Live/UpdateTest.php @@ -25,8 +25,7 @@ final class UpdateTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testUpdateSetsAllWithoutWhere() { @@ -211,4 +210,23 @@ public function testSetWithoutEscape() 'description' => 'Developer', ]); } + + public function testSetWithBoolean() + { + $this->db->table('type_test') + ->set('type_boolean', false) + ->update(); + + $this->seeInDatabase('type_test', [ + 'type_boolean' => false, + ]); + + $this->db->table('type_test') + ->set('type_boolean', true) + ->update(); + + $this->seeInDatabase('type_test', [ + 'type_boolean' => true, + ]); + } } diff --git a/tests/system/Database/Live/WhereTest.php b/tests/system/Database/Live/WhereTest.php index ce87bbd3c7f4..76756e19a219 100644 --- a/tests/system/Database/Live/WhereTest.php +++ b/tests/system/Database/Live/WhereTest.php @@ -24,8 +24,7 @@ final class WhereTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testWhereSimpleKeyValue() { diff --git a/tests/system/Database/Live/WriteTypeQueryTest.php b/tests/system/Database/Live/WriteTypeQueryTest.php index 31dd848c41a2..9ec6c1c1dbe3 100644 --- a/tests/system/Database/Live/WriteTypeQueryTest.php +++ b/tests/system/Database/Live/WriteTypeQueryTest.php @@ -25,8 +25,7 @@ final class WriteTypeQueryTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - - protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; public function testSet() { diff --git a/tests/system/Database/Migrations/MigrationRunnerTest.php b/tests/system/Database/Migrations/MigrationRunnerTest.php index 9dee57a3434e..27e14c51c0f7 100644 --- a/tests/system/Database/Migrations/MigrationRunnerTest.php +++ b/tests/system/Database/Migrations/MigrationRunnerTest.php @@ -33,7 +33,6 @@ final class MigrationRunnerTest extends CIUnitTestCase use DatabaseTestTrait; protected $refresh = true; - protected $root; protected $start; protected $config; @@ -93,7 +92,7 @@ public function testGetHistory() ]; if ($this->db->DBDriver === 'SQLSRV') { - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->prefixTable('migrations') . ' ON'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' ON'); } $this->hasInDatabase('migrations', $expected); @@ -110,8 +109,7 @@ public function testGetHistory() $this->assertSame($expected, $history); if ($this->db->DBDriver === 'SQLSRV') { - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->prefixTable('migrations') . ' OFF'); - + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' OFF'); $db = $this->getPrivateProperty($runner, 'db'); $db->table('migrations')->delete(['id' => 4]); } diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 115ae335c5e1..9bfcbb93c08b 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -11,27 +11,64 @@ namespace CodeIgniter\Debug; +use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; +use Config\Exceptions as ExceptionsConfig; use Config\Services; +use RuntimeException; /** * @internal */ final class ExceptionsTest extends CIUnitTestCase { - public function testNew() + use ReflectionHelper; + + /** + * @var Exceptions + */ + private $exception; + + protected function setUp(): void { - $actual = new Exceptions(new \Config\Exceptions(), Services::request(), Services::response()); - $this->assertInstanceOf(Exceptions::class, $actual); + $this->exception = new Exceptions(new ExceptionsConfig(), Services::request(), Services::response()); + } + + public function testDetermineViews(): void + { + $determineView = $this->getPrivateMethodInvoker($this->exception, 'determineView'); + + $this->assertSame('error_404.php', $determineView(PageNotFoundException::forControllerNotFound('Foo', 'bar'), '')); + $this->assertSame('error_exception.php', $determineView(new RuntimeException('Exception'), '')); + $this->assertSame('error_404.php', $determineView(new RuntimeException('foo', 404), 'app/Views/errors/cli')); + } + + public function testCollectVars(): void + { + $vars = $this->getPrivateMethodInvoker($this->exception, 'collectVars')(new RuntimeException('This.'), 404); + + $this->assertIsArray($vars); + $this->assertCount(7, $vars); + + foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { + $this->assertArrayHasKey($key, $vars); + } + } + + public function testDetermineCodes(): void + { + $determineCodes = $this->getPrivateMethodInvoker($this->exception, 'determineCodes'); + + $this->assertSame([500, 9], $determineCodes(new RuntimeException('This.'))); + $this->assertSame([500, 1], $determineCodes(new RuntimeException('That.', 600))); + $this->assertSame([404, 1], $determineCodes(new RuntimeException('There.', 404))); } /** * @dataProvider dirtyPathsProvider - * - * @param mixed $file - * @param mixed $expected */ - public function testCleanPaths($file, $expected) + public function testCleanPaths(string $file, string $expected): void { $this->assertSame($expected, Exceptions::cleanPath($file)); } @@ -40,7 +77,7 @@ public function dirtyPathsProvider() { $ds = DIRECTORY_SEPARATOR; - return [ + yield from [ [ APPPATH . 'Config' . $ds . 'App.php', 'APPPATH' . $ds . 'Config' . $ds . 'App.php', diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index c995f5d7c191..94d71849c679 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -930,14 +930,12 @@ protected function getEntity() 'default' => 'sumfin', 'created_at' => null, ]; - protected $original = [ 'foo' => null, 'bar' => null, 'default' => 'sumfin', 'created_at' => null, ]; - protected $datamap = [ 'createdAt' => 'created_at', ]; @@ -968,7 +966,6 @@ protected function getMappedEntity() 'foo' => null, 'simple' => null, ]; - protected $_original = [ 'foo' => null, 'simple' => null, @@ -999,12 +996,10 @@ protected function getSwappedEntity() 'foo' => 'foo', 'bar' => 'bar', ]; - protected $_original = [ 'foo' => 'foo', 'bar' => 'bar', ]; - protected $datamap = [ 'bar' => 'foo', 'foo' => 'bar', @@ -1031,7 +1026,6 @@ protected function getCastEntity($data = null): Entity 'twelfth' => null, 'thirteenth' => null, ]; - protected $_original = [ 'first' => null, 'second' => null, @@ -1082,7 +1076,6 @@ protected function getCastNullableEntity() 'integer_0' => null, 'string_value_not_null' => 'value', ]; - protected $_original = [ 'string_null' => null, 'string_empty' => null, @@ -1111,7 +1104,6 @@ protected function getCustomCastEntity() 'third' => null, 'fourth' => null, ]; - protected $_original = [ 'first' => null, 'second' => null, @@ -1126,7 +1118,6 @@ protected function getCustomCastEntity() 'third' => 'type[param1, param2,param3]', 'fourth' => '?type', ]; - protected $castHandlers = [ 'base64' => CastBase64::class, 'someType' => NotExtendsBaseCast::class, diff --git a/tests/system/Files/FileCollectionTest.php b/tests/system/Files/FileCollectionTest.php new file mode 100644 index 000000000000..5621151842e2 --- /dev/null +++ b/tests/system/Files/FileCollectionTest.php @@ -0,0 +1,556 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Files\Exceptions\FileException; +use CodeIgniter\Files\File; +use CodeIgniter\Files\FileCollection; +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +final class FileCollectionTest extends CIUnitTestCase +{ + /** + * A known, valid file + * + * @var string + */ + private $file = SUPPORTPATH . 'Files/baker/banana.php'; + + /** + * A known, valid directory + * + * @var string + */ + private $directory = SUPPORTPATH . 'Files/able/'; + + /** + * Initialize the helper, since some + * tests call static methods before + * the constructor would load it. + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper(['filesystem']); + } + + public function testResolveDirectoryDirectory() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($this->directory)); + } + + public function testResolveDirectoryFile() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedDirectory', ['invokeArgs'])); + + $method($this->file); + } + + public function testResolveDirectorySymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->directory, $link); + + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($link)); + + unlink($link); + } + + public function testResolveFileFile() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->assertSame($this->file, $method($this->file)); + } + + public function testResolveFileSymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->file, $link); + + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->assertSame($this->file, $method($link)); + + unlink($link); + } + + public function testResolveFileDirectory() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['invokeArgs'])); + + $method($this->directory); + } + + public function testConstructorAddsFiles() + { + $expected = [ + $this->directory . 'apple.php', + $this->file, + ]; + + $collection = new class ([$this->file]) extends FileCollection { + protected $files = [ + SUPPORTPATH . 'Files/able/apple.php', + ]; + }; + + $this->assertSame($expected, $collection->get()); + } + + public function testConstructorCallsDefine() + { + $collection = new class () extends FileCollection { + protected function define(): void + { + $this->add(SUPPORTPATH . 'Files/baker/banana.php'); + } + }; + + $this->assertSame([$this->file], $collection->get()); + } + + public function testAddStringFile() + { + $files = new FileCollection(); + + $files->add(SUPPORTPATH . 'Files/baker/banana.php'); + + $this->assertSame([$this->file], $files->get()); + } + + public function testAddStringFileRecursiveDoesNothing() + { + $files = new FileCollection(); + + $files->add(SUPPORTPATH . 'Files/baker/banana.php', true); + + $this->assertSame([$this->file], $files->get()); + } + + public function testAddStringDirectory() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $files->add(SUPPORTPATH . 'Files/able'); + + $this->assertSame($expected, $files->get()); + } + + public function testAddStringDirectoryRecursive() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $files->add(SUPPORTPATH . 'Files'); + + $this->assertSame($expected, $files->get()); + } + + public function testAddArray() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $files->add([ + SUPPORTPATH . 'Files/able', + SUPPORTPATH . 'Files/baker/banana.php', + ]); + + $this->assertSame($expected, $files->get()); + } + + public function testAddArrayRecursive() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $files->add([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $files->get()); + } + + public function testAddFile() + { + $collection = new FileCollection(); + $this->assertSame([], $this->getPrivateProperty($collection, 'files')); + + $collection->addFile($this->file); + $this->assertSame([$this->file], $this->getPrivateProperty($collection, 'files')); + } + + public function testAddFileMissing() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->addFile('TheHillsAreAlive.bmp'); + } + + public function testAddFileDirectory() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->addFile($this->directory); + } + + public function testAddFiles() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->file, + ]; + + $collection->addFiles($files); + $this->assertSame($files, $this->getPrivateProperty($collection, 'files')); + } + + public function testGet() + { + $collection = new FileCollection(); + $collection->addFile($this->file); + + $this->assertSame([$this->file], $collection->get()); + } + + public function testGetSorts() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $this->assertSame(array_reverse($files), $collection->get()); + } + + public function testGetUniques() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->file, + ]; + + $collection->addFiles($files); + $this->assertSame([$this->file], $collection->get()); + } + + public function testSet() + { + $collection = new FileCollection(); + + $collection->set([$this->file]); + $this->assertSame([$this->file], $collection->get()); + } + + public function testSetInvalid() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->set(['flerb']); + } + + public function testRemoveFile() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $collection->removeFile($this->file); + + $this->assertSame([$this->directory . 'apple.php'], $collection->get()); + } + + public function testRemoveFiles() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $collection->removeFiles($files); + + $this->assertSame([], $collection->get()); + } + + public function testAddDirectoryInvalid() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedDirectory', ['addDirectory'])); + + $collection->addDirectory($this->file); + } + + public function testAddDirectory() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $collection->addDirectory($this->directory); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectoryRecursive() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectories() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->addDirectories([ + $this->directory, + SUPPORTPATH . 'Files/baker', + ]); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectoriesRecursive() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $collection->addDirectories([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $collection->get()); + } + + public function testRemovePatternEmpty() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $collection->get(); + + $collection->removePattern(''); + + $this->assertSame($files, $collection->get()); + } + + public function testRemovePatternRegex() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRemovePatternPseudo() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('*_*.php'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRemovePatternScope() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('*.php', $this->directory); + + $this->assertSame($expected, $collection->get()); + } + + public function testRetainPatternEmpty() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $collection->get(); + + $collection->retainPattern(''); + + $this->assertSame($files, $collection->get()); + } + + public function testRetainPatternRegex() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $collection->retainPattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRetainPatternPseudo() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + ]; + + $collection->retainPattern('*_?.php'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRetainPatternScope() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->retainPattern('*_?.php', $this->directory); + + $this->assertSame($expected, $collection->get()); + } + + public function testCount() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertCount(4, $collection); + } + + public function testIterable() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $count = 0; + + foreach ($collection as $file) { + $this->assertInstanceOf(File::class, $file); + $count++; + } + + $this->assertSame($count, 4); + } +} diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index dbe88c4879c5..6f5a94941f81 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -13,8 +13,12 @@ use CodeIgniter\Config\Services; use CodeIgniter\Filters\Exceptions\FilterException; +use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Filters as FiltersConfig; +use LogicException; require_once __DIR__ . '/fixtures/GoogleMe.php'; require_once __DIR__ . '/fixtures/GoogleYou.php'; @@ -32,41 +36,84 @@ */ final class FiltersTest extends CIUnitTestCase { - protected $request; protected $response; protected function setUp(): void { parent::setUp(); - Services::reset(); + + $this->resetServices(); $defaults = [ 'Config' => APPPATH . 'Config', 'App' => APPPATH, 'Tests\Support' => TESTPATH . '_support', ]; - Services::autoloader()->addNamespace($defaults); - $this->request = Services::request(); + $_SERVER = []; + $this->response = Services::response(); } + private function createFilters(FiltersConfig $config, $request = null): Filters + { + $request = $request ?? Services::request(); + + return new Filters($config, $request, $this->response); + } + + /** + * @template T + * + * @param class-string $classname + * + * @return T + */ + private function createConfigFromArray(string $classname, array $config) + { + $configObj = new $classname(); + + foreach ($config as $key => $value) { + if (property_exists($configObj, $key)) { + $configObj->{$key} = $value; + + continue; + } + + throw new LogicException( + 'No such property: ' . $classname . '::$' . $key + ); + } + + return $configObj; + } + public function testProcessMethodDetectsCLI() { + $_SERVER['argv'] = [ + 'spark', + 'list', + ]; + $_SERVER['argc'] = 2; + $config = [ 'aliases' => ['foo' => ''], + 'globals' => [], 'methods' => [ 'cli' => ['foo'], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters( + $filtersConfig, + new CLIRequest(new MockAppConfig()) + ); $expected = [ 'before' => ['foo'], 'after' => [], ]; - $this->assertSame($expected, $filters->initialize()->getFilters()); } @@ -76,17 +123,18 @@ public function testProcessMethodDetectsGetRequests() $config = [ 'aliases' => ['foo' => ''], + 'globals' => [], 'methods' => [ 'get' => ['foo'], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $expected = [ 'before' => ['foo'], 'after' => [], ]; - $this->assertSame($expected, $filters->initialize()->getFilters()); } @@ -99,18 +147,19 @@ public function testProcessMethodRespectsMethod() 'foo' => '', 'bar' => '', ], + 'globals' => [], 'methods' => [ 'post' => ['foo'], 'get' => ['bar'], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $expected = [ 'before' => ['bar'], 'after' => [], ]; - $this->assertSame($expected, $filters->initialize()->getFilters()); } @@ -123,18 +172,19 @@ public function testProcessMethodIgnoresMethod() 'foo' => '', 'bar' => '', ], + 'globals' => [], 'methods' => [ 'post' => ['foo'], 'get' => ['bar'], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $expected = [ 'before' => [], 'after' => [], ]; - $this->assertSame($expected, $filters->initialize()->getFilters()); } @@ -158,8 +208,8 @@ public function testProcessMethodProcessGlobals() ], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $expected = [ 'before' => [ @@ -168,7 +218,6 @@ public function testProcessMethodProcessGlobals() ], 'after' => ['baz'], ]; - $this->assertSame($expected, $filters->initialize()->getFilters()); } @@ -207,16 +256,16 @@ public function testProcessMethodProcessGlobalsWithExcept(array $except) ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'bar', ], 'after' => ['baz'], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -230,6 +279,7 @@ public function testProcessMethodProcessesFiltersBefore() 'bar' => '', 'baz' => '', ], + 'globals' => [], 'filters' => [ 'foo' => [ 'before' => ['admin/*'], @@ -237,14 +287,14 @@ public function testProcessMethodProcessesFiltersBefore() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => ['foo'], 'after' => [], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -258,6 +308,7 @@ public function testProcessMethodProcessesFiltersAfter() 'bar' => '', 'baz' => '', ], + 'globals' => [], 'filters' => [ 'foo' => [ 'before' => ['admin/*'], @@ -265,16 +316,16 @@ public function testProcessMethodProcessesFiltersAfter() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'users/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'users/foo/bar'; $expected = [ 'before' => [], 'after' => [ 'foo', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -311,9 +362,10 @@ public function testProcessMethodProcessesCombined() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'barg', @@ -322,7 +374,6 @@ public function testProcessMethodProcessesCombined() ], 'after' => ['bazg'], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -352,9 +403,10 @@ public function testProcessMethodProcessesCombinedAfterForToolbar() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => ['bar'], 'after' => [ @@ -363,7 +415,6 @@ public function testProcessMethodProcessesCombinedAfterForToolbar() 'toolbar', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -378,12 +429,12 @@ public function testRunThrowsWithInvalidAlias() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $this->expectException(FilterException::class); - $uri = 'admin/foo/bar'; + $uri = 'admin/foo/bar'; $filters->run($uri); } @@ -398,15 +449,33 @@ public function testCustomFiltersLoad() 'after' => [], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); $uri = 'admin/foo/bar'; - $request = $filters->run($uri, 'before'); $this->assertSame('http://hellowworld.com', $request->url); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4720 + */ + public function testAllCustomFiltersAreDiscoveredInConstructor() + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $config = [ + 'aliases' => [], + 'globals' => [], + ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + + $configFilters = $this->getPrivateProperty($filters, 'config'); + $this->assertContains('test-customfilter', array_keys($configFilters->aliases)); + } + public function testRunThrowsWithInvalidClassType() { $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -418,12 +487,12 @@ public function testRunThrowsWithInvalidClassType() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $this->expectException(FilterException::class); - $uri = 'admin/foo/bar'; + $uri = 'admin/foo/bar'; $filters->run($uri); } @@ -438,10 +507,10 @@ public function testRunDoesBefore() 'after' => [], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); $uri = 'admin/foo/bar'; - $request = $filters->run($uri, 'before'); $this->assertSame('http://google.com', $request->url); @@ -458,10 +527,10 @@ public function testRunDoesAfter() 'after' => ['google'], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; - + $uri = 'admin/foo/bar'; $response = $filters->run($uri, 'after'); $this->assertSame('http://google.com', $response->csp); @@ -478,11 +547,12 @@ public function testShortCircuit() 'after' => [], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; - + $uri = 'admin/foo/bar'; $response = $filters->run($uri, 'before'); + $this->assertTrue($response instanceof ResponseInterface); $this->assertSame('http://google.com', $response->csp); } @@ -504,10 +574,10 @@ public function testOtherResult() 'after' => [], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; - + $uri = 'admin/foo/bar'; $response = $filters->run($uri, 'before'); $this->assertSame('This is curious', $response); @@ -533,16 +603,16 @@ public function testBeforeExceptString() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'bar', ], 'after' => ['baz'], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -566,9 +636,10 @@ public function testBeforeExceptInapplicable() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'foo', @@ -576,7 +647,6 @@ public function testBeforeExceptInapplicable() ], 'after' => ['baz'], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -600,16 +670,16 @@ public function testAfterExceptString() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'bar', ], 'after' => ['baz'], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -633,9 +703,10 @@ public function testAfterExceptInapplicable() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'bar', @@ -645,7 +716,6 @@ public function testAfterExceptInapplicable() 'baz', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -660,13 +730,11 @@ public function testAddFilter() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $filters = $filters->addFilter('Some\Class', 'some_alias'); - $filters = $filters->initialize('admin/foo/bar'); - $filters = $filters->getFilters(); $this->assertTrue(in_array('some_alias', $filters['before'], true)); @@ -676,15 +744,14 @@ public function testAddFilterSection() { $_SERVER['REQUEST_METHOD'] = 'GET'; - $config = []; + $config = []; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - - $filters = $filters + $list = $filters ->addFilter('Some\OtherClass', 'another', 'before', 'globals') - ->initialize('admin/foo/bar'); - - $list = $filters->getFilters(); + ->initialize('admin/foo/bar') + ->getFilters(); $this->assertTrue(in_array('another', $list['before'], true)); } @@ -693,16 +760,15 @@ public function testInitializeTwice() { $_SERVER['REQUEST_METHOD'] = 'GET'; - $config = []; - - $filters = new Filters((object) $config, $this->request, $this->response); + $config = []; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = $filters + $list = $filters ->addFilter('Some\OtherClass', 'another', 'before', 'globals') ->initialize('admin/foo/bar') - ->initialize(); - - $list = $filters->getFilters(); + ->initialize() + ->getFilters(); $this->assertTrue(in_array('another', $list['before'], true)); } @@ -718,13 +784,11 @@ public function testEnableFilter() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $filters = $filters->initialize('admin/foo/bar'); - $filters->enableFilter('google', 'before'); - $filters = $filters->getFilters(); $this->assertTrue(in_array('google', $filters['before'], true)); @@ -741,13 +805,12 @@ public function testEnableFilterWithArguments() 'after' => [], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); $filters = $filters->initialize('admin/foo/bar'); - $filters->enableFilter('role:admin , super', 'before'); $filters->enableFilter('role:admin , super', 'after'); - $found = $filters->getFilters(); $this->assertTrue(in_array('role', $found['before'], true)); @@ -755,9 +818,11 @@ public function testEnableFilterWithArguments() $this->assertSame(['role' => ['admin', 'super']], $filters->getArguments()); $response = $filters->run('admin/foo/bar', 'before'); + $this->assertSame('admin;super', $response); $response = $filters->run('admin/foo/bar', 'after'); + $this->assertSame('admin;super', $response->getBody()); } @@ -772,28 +837,28 @@ public function testEnableFilterWithNoArguments() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $filters = $filters->initialize('admin/foo/bar'); - $filters->enableFilter('role', 'before'); $filters->enableFilter('role', 'after'); - $found = $filters->getFilters(); $this->assertTrue(in_array('role', $found['before'], true)); $response = $filters->run('admin/foo/bar', 'before'); + $this->assertSame('Is null', $response); $response = $filters->run('admin/foo/bar', 'after'); + $this->assertSame('Is null', $response->getBody()); } public function testEnableNonFilter() { - $this->expectException('CodeIgniter\Filters\Exceptions\FilterException'); + $this->expectException(FilterException::class); $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -804,11 +869,10 @@ public function testEnableNonFilter() 'after' => [], ], ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $filters = $filters->initialize('admin/foo/bar'); - $filters->enableFilter('goggle', 'before'); } @@ -843,9 +907,10 @@ public function testMatchesURICaseInsensitively() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $expected = [ 'before' => [ 'bar', @@ -856,7 +921,6 @@ public function testMatchesURICaseInsensitively() 'frak', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -873,6 +937,7 @@ public function testFilterMatching() 'bar' => '', 'frak' => '', ], + 'globals' => [], 'filters' => [ 'frak' => [ 'before' => ['admin*'], @@ -880,9 +945,11 @@ public function testFilterMatching() ], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin'; + $uri = 'admin'; + $actual = $filters->initialize($uri)->getFilters(); $expected = [ 'before' => [ @@ -890,8 +957,6 @@ public function testFilterMatching() ], 'after' => [], ]; - - $actual = $filters->initialize($uri)->getFilters(); $this->assertSame($expected, $actual); } @@ -919,9 +984,11 @@ public function testGlobalFilterMatching() ], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin'; + $uri = 'admin'; + $actual = $filters->initialize($uri)->getFilters(); $expected = [ 'before' => [ @@ -932,8 +999,6 @@ public function testGlobalFilterMatching() 'two', ], ]; - - $actual = $filters->initialize($uri)->getFilters(); $this->assertSame($expected, $actual); } @@ -968,10 +1033,10 @@ public function testCombinedFilterMatching() ], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin123'; - + $uri = 'admin123'; $expected = [ 'before' => [ 'one', @@ -982,7 +1047,6 @@ public function testCombinedFilterMatching() 'two', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -1014,10 +1078,10 @@ public function testSegmentedFilterMatching() ], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/123'; - + $uri = 'admin/123'; $expected = [ 'before' => [ 'frak', @@ -1026,7 +1090,6 @@ public function testSegmentedFilterMatching() 'frak', ], ]; - $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } @@ -1048,10 +1111,12 @@ public function testFilterAlitasMultiple() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin/foo/bar'; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + $uri = 'admin/foo/bar'; $request = $filters->run($uri, 'before'); + $this->assertSame('http://exampleMultipleURL.com', $request->url); $this->assertSame('http://exampleMultipleCSP.com', $request->csp); } @@ -1071,9 +1136,11 @@ public function testFilterClass() ], ], ]; - $filters = new Filters((object) $config, $this->request, $this->response); + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); $filters->run('admin/foo/bar', 'before'); + $expected = [ 'before' => [], 'after' => [ @@ -1092,16 +1159,17 @@ public function testReset() 'aliases' => [ 'foo' => '', ], + 'globals' => [], 'filters' => [ 'foo' => [ 'before' => ['admin*'], ], ], ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); - $filters = new Filters((object) $config, $this->request, $this->response); - $uri = 'admin'; - + $uri = 'admin'; $this->assertSame(['foo'], $filters->initialize($uri)->getFilters()['before']); $this->assertSame([], $filters->reset()->getFilters()['before']); } diff --git a/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php b/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php new file mode 100644 index 000000000000..4d35d4510271 --- /dev/null +++ b/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php @@ -0,0 +1,1001 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockCURLRequest; +use Config\App; +use Config\CURLRequest as ConfigCURLRequest; +use CURLFile; + +/** + * @internal + */ +final class CURLRequestDoNotShareOptionsTest extends CIUnitTestCase +{ + /** + * @var MockCURLRequest + */ + protected $request; + + protected function setUp(): void + { + parent::setUp(); + + Services::reset(); + $this->request = $this->getRequest(); + } + + protected function getRequest(array $options = []) + { + $uri = isset($options['base_uri']) ? new URI($options['base_uri']) : new URI(); + $app = new App(); + + $config = new ConfigCURLRequest(); + $config->shareOptions = false; + Factories::injectMock('config', 'CURLRequest', $config); + + return new MockCURLRequest(($app), $uri, new Response($app), $options); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4707 + */ + public function testPrepareURLIgnoresAppConfig() + { + config('App')->baseURL = 'http://example.com/fruit/'; + + $request = $this->getRequest(['base_uri' => 'http://example.com/v1/']); + + $method = $this->getPrivateMethodInvoker($request, 'prepareURL'); + + $this->assertSame('http://example.com/v1/bananas', $method('bananas')); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/1029 + */ + public function testGetRemembersBaseURI() + { + $request = $this->getRequest(['base_uri' => 'http://www.foo.com/api/v1/']); + + $request->get('products'); + + $options = $request->curl_options; + + $this->assertSame('http://www.foo.com/api/v1/products', $options[CURLOPT_URL]); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/1029 + */ + public function testGetRemembersBaseURIWithHelperMethod() + { + $request = Services::curlrequest(['base_uri' => 'http://www.foo.com/api/v1/']); + + $uri = $this->getPrivateProperty($request, 'baseURI'); + $this->assertSame('www.foo.com', $uri->getHost()); + $this->assertSame('/api/v1/', $uri->getPath()); + } + + public function testSendReturnsResponse() + { + $output = 'Howdy Stranger.'; + + $response = $this->request->setOutput($output)->send('get', 'http://example.com'); + + $this->assertInstanceOf('CodeIgniter\\HTTP\\Response', $response); + $this->assertSame($output, $response->getBody()); + } + + public function testGetSetsCorrectMethod() + { + $this->request->get('http://example.com'); + + $this->assertSame('get', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('GET', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testDeleteSetsCorrectMethod() + { + $this->request->delete('http://example.com'); + + $this->assertSame('delete', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('DELETE', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testHeadSetsCorrectMethod() + { + $this->request->head('http://example.com'); + + $this->assertSame('head', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('HEAD', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testOptionsSetsCorrectMethod() + { + $this->request->options('http://example.com'); + + $this->assertSame('options', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('OPTIONS', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testOptionsBaseURIOption() + { + $options = ['base_uri' => 'http://www.foo.com/api/v1/']; + $request = $this->getRequest($options); + + $this->assertSame('http://www.foo.com/api/v1/', $request->getBaseURI()->__toString()); + } + + public function testOptionsBaseURIOverride() + { + $options = [ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'baseURI' => 'http://bogus/com', + ]; + $request = $this->getRequest($options); + + $this->assertSame('http://bogus/com', $request->getBaseURI()->__toString()); + } + + public function testOptionsHeaders() + { + $options = [ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'headers' => ['fruit' => 'apple'], + ]; + $request = $this->getRequest(); + $this->assertNull($request->header('fruit')); + + $request = $this->getRequest($options); + $this->assertSame('apple', $request->header('fruit')->getValue()); + } + + /** + * @backupGlobals enabled + */ + public function testOptionsHeadersNotUsingPopulate() + { + $_SERVER['HTTP_HOST'] = 'site1.com'; + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US'; + $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip, deflate, br'; + + $options = [ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'headers' => [ + 'Host' => 'www.foo.com', + 'Accept-Encoding' => '', + ], + ]; + $request = $this->getRequest($options); + $request->get('example'); + // if headers for the request are defined we use them + $this->assertNull($request->header('Accept-Language')); + $this->assertSame('www.foo.com', $request->header('Host')->getValue()); + $this->assertSame('', $request->header('Accept-Encoding')->getValue()); + } + + public function testDefaultOptionsAreSharedBetweenRequests() + { + $options = [ + 'form_params' => ['studio' => 1], + 'user_agent' => 'CodeIgniter Framework v4', + ]; + $request = $this->getRequest($options); + + $request->request('POST', 'https://realestate1.example.com'); + + $this->assertSame('https://realestate1.example.com', $request->curl_options[CURLOPT_URL]); + $this->assertSame('studio=1', $request->curl_options[CURLOPT_POSTFIELDS]); + $this->assertSame('CodeIgniter Framework v4', $request->curl_options[CURLOPT_USERAGENT]); + + $request->request('POST', 'https://realestate2.example.com'); + + $this->assertSame('https://realestate2.example.com', $request->curl_options[CURLOPT_URL]); + $this->assertSame('studio=1', $request->curl_options[CURLOPT_POSTFIELDS]); + $this->assertSame('CodeIgniter Framework v4', $request->curl_options[CURLOPT_USERAGENT]); + } + + public function testHeaderContentLengthNotSharedBetweenRequests() + { + $options = [ + 'base_uri' => 'http://www.foo.com/api/v1/', + ]; + $request = $this->getRequest($options); + + $request->post('example', [ + 'form_params' => [ + 'q' => 'keyword', + ], + ]); + $request->get('example'); + + $this->assertNull($request->header('Content-Length')); + } + + /** + * @backupGlobals enabled + */ + public function testHeaderContentLengthNotSharedBetweenClients() + { + $_SERVER['HTTP_CONTENT_LENGTH'] = '10'; + + $options = [ + 'base_uri' => 'http://www.foo.com/api/v1/', + ]; + $request = $this->getRequest($options); + $request->post('example', [ + 'form_params' => [ + 'q' => 'keyword', + ], + ]); + + $request = $this->getRequest($options); + $request->get('example'); + + $this->assertNull($request->header('Content-Length')); + } + + public function testOptionsDelay() + { + $options = [ + 'delay' => 2000, + 'headers' => ['fruit' => 'apple'], + ]; + $request = $this->getRequest(); + $this->assertSame(0.0, $request->getDelay()); + + $request = $this->getRequest($options); + $this->assertSame(2.0, $request->getDelay()); + } + + public function testPatchSetsCorrectMethod() + { + $this->request->patch('http://example.com'); + + $this->assertSame('patch', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('PATCH', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testPostSetsCorrectMethod() + { + $this->request->post('http://example.com'); + + $this->assertSame('post', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('POST', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testPutSetsCorrectMethod() + { + $this->request->put('http://example.com'); + + $this->assertSame('put', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('PUT', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testCustomMethodSetsCorrectMethod() + { + $this->request->request('custom', 'http://example.com'); + + $this->assertSame('custom', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('CUSTOM', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testRequestMethodGetsSanitized() + { + $this->request->request('', 'http://example.com'); + + $this->assertSame('custom', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CUSTOMREQUEST, $options); + $this->assertSame('CUSTOM', $options[CURLOPT_CUSTOMREQUEST]); + } + + public function testRequestSetsBasicCurlOptions() + { + $this->request->request('get', 'http://example.com'); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_URL, $options); + $this->assertSame('http://example.com', $options[CURLOPT_URL]); + + $this->assertArrayHasKey(CURLOPT_RETURNTRANSFER, $options); + $this->assertTrue($options[CURLOPT_RETURNTRANSFER]); + + $this->assertArrayHasKey(CURLOPT_HEADER, $options); + $this->assertTrue($options[CURLOPT_HEADER]); + + $this->assertArrayHasKey(CURLOPT_FRESH_CONNECT, $options); + $this->assertTrue($options[CURLOPT_FRESH_CONNECT]); + + $this->assertArrayHasKey(CURLOPT_TIMEOUT_MS, $options); + $this->assertSame(0.0, $options[CURLOPT_TIMEOUT_MS]); + + $this->assertArrayHasKey(CURLOPT_CONNECTTIMEOUT_MS, $options); + $this->assertSame(150000.0, $options[CURLOPT_CONNECTTIMEOUT_MS]); + } + + public function testAuthBasicOption() + { + $this->request->request('get', 'http://example.com', [ + 'auth' => [ + 'username', + 'password', + ], + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + $this->assertSame('username:password', $options[CURLOPT_USERPWD]); + + $this->assertArrayHasKey(CURLOPT_HTTPAUTH, $options); + $this->assertSame(CURLAUTH_BASIC, $options[CURLOPT_HTTPAUTH]); + } + + public function testAuthBasicOptionExplicit() + { + $this->request->request('get', 'http://example.com', [ + 'auth' => [ + 'username', + 'password', + 'basic', + ], + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + $this->assertSame('username:password', $options[CURLOPT_USERPWD]); + + $this->assertArrayHasKey(CURLOPT_HTTPAUTH, $options); + $this->assertSame(CURLAUTH_BASIC, $options[CURLOPT_HTTPAUTH]); + } + + public function testAuthDigestOption() + { + $output = "HTTP/1.1 401 Unauthorized + Server: ddos-guard + Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT + WWW-Authenticate: Digest\x0d\x0a\x0d\x0aHTTP/1.1 200 OK + Server: ddos-guard + Connection: keep-alive + Keep-Alive: timeout=60 + Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT + Date: Tue, 07 Jul 2020 15:13:14 GMT + Expires: Thu, 19 Nov 1981 08:52:00 GMT + Cache-Control: no-store, no-cache, must-revalidate + Pragma: no-cache + Set-Cookie: PHPSESSID=80pd3hlg38mvjnelpvokp9lad0; path=/ + Content-Type: application/xml; charset=utf-8 + Transfer-Encoding: chunked\x0d\x0a\x0d\x0aUpdate success! config"; + + $this->request->setOutput($output); + + $response = $this->request->request('get', 'http://example.com', [ + 'auth' => [ + 'username', + 'password', + 'digest', + ], + ]); + + $options = $this->request->curl_options; + + $this->assertSame('Update success! config', $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + $this->assertSame('username:password', $options[CURLOPT_USERPWD]); + + $this->assertArrayHasKey(CURLOPT_HTTPAUTH, $options); + $this->assertSame(CURLAUTH_DIGEST, $options[CURLOPT_HTTPAUTH]); + } + + public function testSetAuthBasic() + { + $this->request->setAuth('username', 'password')->get('http://example.com'); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + $this->assertSame('username:password', $options[CURLOPT_USERPWD]); + + $this->assertArrayHasKey(CURLOPT_HTTPAUTH, $options); + $this->assertSame(CURLAUTH_BASIC, $options[CURLOPT_HTTPAUTH]); + } + + public function testSetAuthDigest() + { + $output = "HTTP/1.1 401 Unauthorized + Server: ddos-guard + Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT + WWW-Authenticate: Digest\x0d\x0a\x0d\x0aHTTP/1.1 200 OK + Server: ddos-guard + Connection: keep-alive + Keep-Alive: timeout=60 + Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT + Date: Tue, 07 Jul 2020 15:13:14 GMT + Expires: Thu, 19 Nov 1981 08:52:00 GMT + Cache-Control: no-store, no-cache, must-revalidate + Pragma: no-cache + Set-Cookie: PHPSESSID=80pd3hlg38mvjnelpvokp9lad0; path=/ + Content-Type: application/xml; charset=utf-8 + Transfer-Encoding: chunked\x0d\x0a\x0d\x0aUpdate success! config"; + + $this->request->setOutput($output); + + $response = $this->request->setAuth('username', 'password', 'digest')->get('http://example.com'); + + $options = $this->request->curl_options; + + $this->assertSame('Update success! config', $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + $this->assertSame('username:password', $options[CURLOPT_USERPWD]); + + $this->assertArrayHasKey(CURLOPT_HTTPAUTH, $options); + $this->assertSame(CURLAUTH_DIGEST, $options[CURLOPT_HTTPAUTH]); + } + + public function testCertOption() + { + $file = __FILE__; + + $this->request->request('get', 'http://example.com', [ + 'cert' => $file, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_SSLCERT, $options); + $this->assertSame($file, $options[CURLOPT_SSLCERT]); + } + + public function testCertOptionWithPassword() + { + $file = __FILE__; + + $this->request->request('get', 'http://example.com', [ + 'cert' => [ + $file, + 'password', + ], + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_SSLCERT, $options); + $this->assertSame($file, $options[CURLOPT_SSLCERT]); + + $this->assertArrayHasKey(CURLOPT_SSLCERTPASSWD, $options); + $this->assertSame('password', $options[CURLOPT_SSLCERTPASSWD]); + } + + public function testMissingCertOption() + { + $file = 'something_obviously_bogus'; + $this->expectException(HTTPException::class); + + $this->request->request('get', 'http://example.com', [ + 'cert' => $file, + ]); + } + + public function testSSLVerification() + { + $file = __FILE__; + + $this->request->request('get', 'http://example.com', [ + 'verify' => 'yes', + 'ssl_key' => $file, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_CAINFO, $options); + $this->assertSame($file, $options[CURLOPT_CAINFO]); + + $this->assertArrayHasKey(CURLOPT_SSL_VERIFYPEER, $options); + $this->assertSame(1, $options[CURLOPT_SSL_VERIFYPEER]); + } + + public function testSSLWithBadKey() + { + $file = 'something_obviously_bogus'; + $this->expectException(HTTPException::class); + + $this->request->request('get', 'http://example.com', [ + 'verify' => 'yes', + 'ssl_key' => $file, + ]); + } + + public function testDebugOptionTrue() + { + $this->request->request('get', 'http://example.com', [ + 'debug' => true, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_VERBOSE, $options); + $this->assertSame(1, $options[CURLOPT_VERBOSE]); + + $this->assertArrayHasKey(CURLOPT_STDERR, $options); + $this->assertIsResource($options[CURLOPT_STDERR]); + } + + public function testDebugOptionFalse() + { + $this->request->request('get', 'http://example.com', [ + 'debug' => false, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayNotHasKey(CURLOPT_VERBOSE, $options); + $this->assertArrayNotHasKey(CURLOPT_STDERR, $options); + } + + public function testDebugOptionFile() + { + $file = SUPPORTPATH . 'Files/baker/banana.php'; + + $this->request->request('get', 'http://example.com', [ + 'debug' => $file, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_VERBOSE, $options); + $this->assertSame(1, $options[CURLOPT_VERBOSE]); + + $this->assertArrayHasKey(CURLOPT_STDERR, $options); + $this->assertIsResource($options[CURLOPT_STDERR]); + } + + public function testDecodeContent() + { + $this->request->setHeader('Accept-Encoding', 'cobol'); + $this->request->request('get', 'http://example.com', [ + 'decode_content' => true, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_ENCODING, $options); + $this->assertSame('cobol', $options[CURLOPT_ENCODING]); + } + + public function testDecodeContentWithoutAccept() + { + // $this->request->setHeader('Accept-Encoding', 'cobol'); + $this->request->request('get', 'http://example.com', [ + 'decode_content' => true, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_ENCODING, $options); + $this->assertSame('', $options[CURLOPT_ENCODING]); + $this->assertArrayHasKey(CURLOPT_HTTPHEADER, $options); + $this->assertSame('Accept-Encoding', $options[CURLOPT_HTTPHEADER]); + } + + public function testAllowRedirectsOptionFalse() + { + $this->request->request('get', 'http://example.com', [ + 'allow_redirects' => false, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FOLLOWLOCATION, $options); + $this->assertSame(0, $options[CURLOPT_FOLLOWLOCATION]); + + $this->assertArrayNotHasKey(CURLOPT_MAXREDIRS, $options); + $this->assertArrayNotHasKey(CURLOPT_REDIR_PROTOCOLS, $options); + } + + public function testAllowRedirectsOptionTrue() + { + $this->request->request('get', 'http://example.com', [ + 'allow_redirects' => true, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FOLLOWLOCATION, $options); + $this->assertSame(1, $options[CURLOPT_FOLLOWLOCATION]); + + $this->assertArrayHasKey(CURLOPT_MAXREDIRS, $options); + $this->assertSame(5, $options[CURLOPT_MAXREDIRS]); + $this->assertArrayHasKey(CURLOPT_REDIR_PROTOCOLS, $options); + $this->assertSame(CURLPROTO_HTTP | CURLPROTO_HTTPS, $options[CURLOPT_REDIR_PROTOCOLS]); + } + + public function testAllowRedirectsOptionDefaults() + { + $this->request->request('get', 'http://example.com', [ + 'allow_redirects' => true, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FOLLOWLOCATION, $options); + $this->assertSame(1, $options[CURLOPT_FOLLOWLOCATION]); + + $this->assertArrayHasKey(CURLOPT_MAXREDIRS, $options); + $this->assertArrayHasKey(CURLOPT_REDIR_PROTOCOLS, $options); + } + + public function testAllowRedirectsArray() + { + $this->request->request('get', 'http://example.com', [ + 'allow_redirects' => ['max' => 2], + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FOLLOWLOCATION, $options); + $this->assertSame(1, $options[CURLOPT_FOLLOWLOCATION]); + + $this->assertArrayHasKey(CURLOPT_MAXREDIRS, $options); + $this->assertSame(2, $options[CURLOPT_MAXREDIRS]); + } + + public function testSendWithQuery() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'query' => [ + 'name' => 'Henry', + 'd.t' => 'value', + ], + ]); + + $request->get('products'); + + $options = $request->curl_options; + + $this->assertSame('http://www.foo.com/api/v1/products?name=Henry&d.t=value', $options[CURLOPT_URL]); + } + + public function testSendWithDelay() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->get('products'); + + // we still need to check the code coverage to make sure this was done + $this->assertSame(1.0, $request->getDelay()); + } + + public function testSendContinued() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->setOutput("HTTP/1.1 100 Continue\x0d\x0a\x0d\x0aHi there"); + $response = $request->get('answer'); + $this->assertSame('Hi there', $response->getBody()); + } + + /** + * See: https://github.com/codeigniter4/CodeIgniter4/issues/3261 + */ + public function testSendContinuedWithManyHeaders() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $output = "HTTP/1.1 100 Continue +Server: ddos-guard +Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT\x0d\x0a\x0d\x0aHTTP/1.1 200 OK +Server: ddos-guard +Connection: keep-alive +Keep-Alive: timeout=60 +Set-Cookie: __ddg1=z177j4mLtqzC07v0zviU; Domain=.site.ru; HttpOnly; Path=/; Expires=Wed, 07-Jul-2021 15:13:14 GMT +Date: Tue, 07 Jul 2020 15:13:14 GMT +Expires: Thu, 19 Nov 1981 08:52:00 GMT +Cache-Control: no-store, no-cache, must-revalidate +Pragma: no-cache +Set-Cookie: PHPSESSID=80pd3hlg38mvjnelpvokp9lad0; path=/ +Content-Type: application/xml; charset=utf-8 +Transfer-Encoding: chunked\x0d\x0a\x0d\x0aUpdate success! config"; + + $request->setOutput($output); + $response = $request->get('answer'); + + $this->assertSame('Update success! config', $response->getBody()); + + $responseHeaderKeys = [ + 'Cache-control', + 'Content-Type', + 'Server', + 'Connection', + 'Keep-Alive', + 'Set-Cookie', + 'Date', + 'Expires', + 'Pragma', + 'Transfer-Encoding', + ]; + $this->assertSame($responseHeaderKeys, array_keys($response->headers())); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testSplitResponse() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->setOutput("Accept: text/html\x0d\x0a\x0d\x0aHi there"); + $response = $request->get('answer'); + $this->assertSame('Hi there', $response->getBody()); + } + + public function testApplyBody() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->setBody('name=George'); + $request->setOutput('Hi there'); + $response = $request->post('answer'); + + $this->assertSame('Hi there', $response->getBody()); + $this->assertSame('name=George', $request->curl_options[CURLOPT_POSTFIELDS]); + } + + public function testResponseHeaders() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->setOutput("HTTP/2.0 234 Ohoh\x0d\x0aAccept: text/html\x0d\x0a\x0d\x0aHi there"); + $response = $request->get('bogus'); + + $this->assertSame('2.0', $response->getProtocolVersion()); + $this->assertSame(234, $response->getStatusCode()); + } + + public function testResponseHeadersShortProtocol() + { + $request = $this->getRequest([ + 'base_uri' => 'http://www.foo.com/api/v1/', + 'delay' => 1000, + ]); + + $request->setOutput("HTTP/2 235 Ohoh\x0d\x0aAccept: text/html\x0d\x0a\x0d\x0aHi there shortie"); + $response = $request->get('bogus'); + + $this->assertSame('2.0', $response->getProtocolVersion()); + $this->assertSame(235, $response->getStatusCode()); + } + + public function testPostFormEncoded() + { + $params = [ + 'foo' => 'bar', + 'baz' => [ + 'hi', + 'there', + ], + ]; + $this->request->request('POST', '/post', [ + 'form_params' => $params, + ]); + + $this->assertSame('post', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $expected = http_build_query($params); + $this->assertArrayHasKey(CURLOPT_POSTFIELDS, $options); + $this->assertSame($expected, $options[CURLOPT_POSTFIELDS]); + } + + public function testPostFormMultipart() + { + $params = [ + 'foo' => 'bar', + 'baz' => [ + 'hi', + 'there', + ], + 'afile' => new CURLFile(__FILE__), + ]; + $this->request->request('POST', '/post', [ + 'multipart' => $params, + ]); + + $this->assertSame('post', $this->request->getMethod()); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_POSTFIELDS, $options); + $this->assertSame($params, $options[CURLOPT_POSTFIELDS]); + } + + public function testSetForm() + { + $params = [ + 'foo' => 'bar', + 'baz' => [ + 'hi', + 'there', + ], + ]; + + $this->request->setForm($params)->post('/post'); + + $this->assertSame( + http_build_query($params), + $this->request->curl_options[CURLOPT_POSTFIELDS] + ); + + $params['afile'] = new CURLFile(__FILE__); + + $this->request->setForm($params, true)->post('/post'); + + $this->assertSame( + $params, + $this->request->curl_options[CURLOPT_POSTFIELDS] + ); + } + + public function testJSONData() + { + $params = [ + 'foo' => 'bar', + 'baz' => [ + 'hi', + 'there', + ], + ]; + $this->request->request('POST', '/post', [ + 'json' => $params, + ]); + + $this->assertSame('post', $this->request->getMethod()); + + $expected = json_encode($params); + $this->assertSame($expected, $this->request->getBody()); + } + + public function testSetJSON() + { + $params = [ + 'foo' => 'bar', + 'baz' => [ + 'hi', + 'there', + ], + ]; + $this->request->setJSON($params)->post('/post'); + + $this->assertSame(json_encode($params), $this->request->getBody()); + $this->assertSame( + 'Content-Type: application/json', + $this->request->curl_options[CURLOPT_HTTPHEADER][0] + ); + } + + public function testHTTPv1() + { + $this->request->request('POST', '/post', [ + 'version' => 1.0, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $options); + $this->assertSame(CURL_HTTP_VERSION_1_0, $options[CURLOPT_HTTP_VERSION]); + } + + public function testHTTPv11() + { + $this->request->request('POST', '/post', [ + 'version' => 1.1, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $options); + $this->assertSame(CURL_HTTP_VERSION_1_1, $options[CURLOPT_HTTP_VERSION]); + } + + public function testCookieOption() + { + $holder = SUPPORTPATH . 'HTTP/Files/CookiesHolder.txt'; + $this->request->request('POST', '/post', [ + 'cookie' => $holder, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_COOKIEJAR, $options); + $this->assertSame($holder, $options[CURLOPT_COOKIEJAR]); + $this->assertArrayHasKey(CURLOPT_COOKIEFILE, $options); + $this->assertSame($holder, $options[CURLOPT_COOKIEFILE]); + } + + public function testUserAgentOption() + { + $agent = 'CodeIgniter Framework'; + + $this->request->request('POST', '/post', [ + 'user_agent' => $agent, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_USERAGENT, $options); + $this->assertSame($agent, $options[CURLOPT_USERAGENT]); + } +} diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index c1ac77765a44..e978944ad116 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -11,11 +11,13 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCURLRequest; use Config\App; +use Config\CURLRequest as ConfigCURLRequest; use CURLFile; /** @@ -32,15 +34,20 @@ protected function setUp(): void { parent::setUp(); - Services::reset(); + $this->resetServices(); $this->request = $this->getRequest(); } protected function getRequest(array $options = []) { $uri = isset($options['base_uri']) ? new URI($options['base_uri']) : new URI(); + $app = new App(); - return new MockCURLRequest(($app = new App()), $uri, new Response($app), $options); + $config = new ConfigCURLRequest(); + $config->shareOptions = true; + Factories::injectMock('config', 'CURLRequest', $config); + + return new MockCURLRequest(($app), $uri, new Response($app), $options); } /** @@ -176,7 +183,7 @@ public function testOptionsHeaders() /** * @backupGlobals enabled */ - public function testOptionHeadersUsingPopulate() + public function testOptionsHeadersNotUsingPopulate() { $_SERVER['HTTP_HOST'] = 'site1.com'; $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US'; @@ -184,40 +191,61 @@ public function testOptionHeadersUsingPopulate() $options = [ 'base_uri' => 'http://www.foo.com/api/v1/', + 'headers' => [ + 'Host' => 'www.foo.com', + 'Accept-Encoding' => '', + ], ]; - $request = $this->getRequest($options); $request->get('example'); - // we fill the Accept-Language header from _SERVER when no headers are defined for the request - $this->assertSame('en-US', $request->header('Accept-Language')->getValue()); - // but we skip Host header - since it would corrupt the request - $this->assertNull($request->header('Host')); - // and Accept-Encoding - $this->assertNull($request->header('Accept-Encoding')); + // if headers for the request are defined we use them + $this->assertNull($request->header('Accept-Language')); + $this->assertSame('www.foo.com', $request->header('Host')->getValue()); + $this->assertSame('', $request->header('Accept-Encoding')->getValue()); + } + + public function testOptionsAreSharedBetweenRequests() + { + $options = [ + 'form_params' => ['studio' => 1], + 'user_agent' => 'CodeIgniter Framework v4', + ]; + $request = $this->getRequest($options); + + $request->request('POST', 'https://realestate1.example.com'); + + $this->assertSame('https://realestate1.example.com', $request->curl_options[CURLOPT_URL]); + $this->assertSame('studio=1', $request->curl_options[CURLOPT_POSTFIELDS]); + $this->assertSame('CodeIgniter Framework v4', $request->curl_options[CURLOPT_USERAGENT]); + + $request->request('POST', 'https://realestate2.example.com'); + + $this->assertSame('https://realestate2.example.com', $request->curl_options[CURLOPT_URL]); + $this->assertSame('studio=1', $request->curl_options[CURLOPT_POSTFIELDS]); + $this->assertSame('CodeIgniter Framework v4', $request->curl_options[CURLOPT_USERAGENT]); } /** * @backupGlobals enabled */ - public function testOptionHeadersNotUsingPopulate() + public function testHeaderContentLengthNotSharedBetweenClients() { - $_SERVER['HTTP_HOST'] = 'site1.com'; - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US'; - $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip, deflate, br'; + $_SERVER['HTTP_CONTENT_LENGTH'] = '10'; $options = [ 'base_uri' => 'http://www.foo.com/api/v1/', - 'headers' => [ - 'Host' => 'www.foo.com', - 'Accept-Encoding' => '', - ], ]; + $request = $this->getRequest($options); + $request->post('example', [ + 'form_params' => [ + 'q' => 'keyword', + ], + ]); + $request = $this->getRequest($options); $request->get('example'); - // if headers for the request are defined we use them - $this->assertNull($request->header('Accept-Language')); - $this->assertSame('www.foo.com', $request->header('Host')->getValue()); - $this->assertSame('', $request->header('Accept-Encoding')->getValue()); + + $this->assertNull($request->header('Content-Length')); } public function testOptionsDelay() @@ -895,7 +923,10 @@ public function testSetJSON() $this->request->setJSON($params)->post('/post'); $this->assertSame(json_encode($params), $this->request->getBody()); - $this->assertSame('application/json', $this->request->getHeaderLine('Content-Type')); + $this->assertSame( + 'Content-Type: application/json', + $this->request->curl_options[CURLOPT_HTTPHEADER][0] + ); } public function testHTTPv1() diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index a64445232aba..1e975359a120 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -247,7 +247,7 @@ public function testNegotiatesNot() public function testNegotiatesCharset() { - // $_SERVER['HTTP_ACCEPT_CHARSET'] = 'iso-8859-5, unicode-1-1;q=0.8'; + // $_SERVER['HTTP_ACCEPT_CHARSET'] = 'iso-8859-5, unicode-1-1;q=0.8'; $this->request->setHeader('Accept-Charset', 'iso-8859-5, unicode-1-1;q=0.8'); $this->assertSame(strtolower($this->request->config->charset), $this->request->negotiate('charset', ['iso-8859', 'unicode-1-2'])); @@ -299,6 +299,8 @@ public function testCanGetAVariableFromJson() $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); $this->assertSame('bar', $request->getJsonVar('foo')); + $this->assertNull($request->getJsonVar('notExists')); + $jsonVar = $request->getJsonVar('baz'); $this->assertIsObject($jsonVar); $this->assertSame('buzz', $jsonVar->fizz); @@ -340,11 +342,7 @@ public function testGetJsonVarCanFilter() public function testGetVarWorksWithJson() { - $jsonObj = [ - 'foo' => 'bar', - 'fizz' => 'buzz', - ]; - $json = json_encode($jsonObj); + $json = json_encode(['foo' => 'bar', 'fizz' => 'buzz']); $config = new App(); $config->baseURL = 'http://example.com/'; @@ -354,6 +352,7 @@ public function testGetVarWorksWithJson() $this->assertSame('bar', $request->getVar('foo')); $this->assertSame('buzz', $request->getVar('fizz')); + $this->assertNull($request->getVar('notExists')); $multiple = $request->getVar(['foo', 'fizz']); $this->assertIsArray($multiple); diff --git a/tests/system/HTTP/RedirectResponseTest.php b/tests/system/HTTP/RedirectResponseTest.php index 0e8e49a74588..ac2e8abaaba9 100644 --- a/tests/system/HTTP/RedirectResponseTest.php +++ b/tests/system/HTTP/RedirectResponseTest.php @@ -30,6 +30,7 @@ final class RedirectResponseTest extends CIUnitTestCase * @var RouteCollection */ protected $routes; + protected $request; protected $config; diff --git a/tests/system/HTTP/URITest.php b/tests/system/HTTP/URITest.php index 89cb106ed607..acd491bccb9d 100644 --- a/tests/system/HTTP/URITest.php +++ b/tests/system/HTTP/URITest.php @@ -861,7 +861,7 @@ public function testSetBadSegmentSilent() public function testBasedNoIndex() { - Services::reset(); + $this->resetServices(); $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; @@ -888,7 +888,7 @@ public function testBasedNoIndex() public function testBasedWithIndex() { - Services::reset(); + $this->resetServices(); $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/ci/v4/index.php/controller/method'; @@ -915,7 +915,7 @@ public function testBasedWithIndex() public function testForceGlobalSecureRequests() { - Services::reset(); + $this->resetServices(); $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; diff --git a/tests/system/Helpers/SecurityHelperTest.php b/tests/system/Helpers/SecurityHelperTest.php index 3e1d8c332080..4d8169f6f712 100644 --- a/tests/system/Helpers/SecurityHelperTest.php +++ b/tests/system/Helpers/SecurityHelperTest.php @@ -12,6 +12,9 @@ namespace CodeIgniter\Helpers; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockSecurity; +use Config\App; +use Tests\Support\Config\Services as Services; /** * @internal @@ -27,6 +30,8 @@ protected function setUp(): void public function testSanitizeFilenameSimpleSuccess() { + Services::injectMock('security', new MockSecurity(new App())); + $this->assertSame('hello.doc', sanitize_filename('hello.doc')); } diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index 2da8e5accbf3..e1e683c98d07 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -28,7 +28,7 @@ protected function setUp(): void parent::setUp(); helper('date'); - Locale::setDefault('America/Chicago'); + Locale::setDefault('en_US'); } public function testNewTimeNow() @@ -90,7 +90,7 @@ public function testTimeWithDateTimeZone() 'yyyy-MM-dd HH:mm:ss' ); - $time = new Time('now', new \DateTimeZone('Europe/London'), 'fr_FR'); + $time = new Time('now', new DateTimeZone('Europe/London'), 'fr_FR'); $this->assertSame($formatter->format($time), (string) $time); } @@ -101,13 +101,13 @@ public function testToDateTime() $obj = $time->toDateTime(); - $this->assertInstanceOf(\DateTime::class, $obj); + $this->assertInstanceOf(DateTime::class, $obj); } public function testNow() { $time = Time::now(); - $time1 = new \DateTime(); + $time1 = new DateTime(); $this->assertInstanceOf(Time::class, $time); $this->assertSame($time->getTimestamp(), $time1->getTimestamp()); @@ -116,7 +116,7 @@ public function testNow() public function testParse() { $time = Time::parse('next Tuesday', 'America/Chicago'); - $time1 = new \DateTime('now', new \DateTimeZone('America/Chicago')); + $time1 = new DateTime('now', new DateTimeZone('America/Chicago')); $time1->modify('next Tuesday'); $this->assertSame($time->getTimestamp(), $time1->getTimestamp()); @@ -134,7 +134,7 @@ public function testToDateTimeStringWithTimeZone() { $time = Time::parse('2017-01-12 00:00', 'Europe/London'); - $expects = new \DateTime('2017-01-12', new \DateTimeZone('Europe/London')); + $expects = new DateTime('2017-01-12', new DateTimeZone('Europe/London')); $this->assertSame($expects->format('Y-m-d H:i:s'), $time->toDateTimeString()); } @@ -204,7 +204,7 @@ public function testCreateFromTimeLocalized() public function testCreateFromFormat() { - $now = new \DateTime('now'); + $now = new DateTime('now'); Time::setTestNow($now); $time = Time::createFromFormat('F j, Y', 'January 15, 2017', 'America/Chicago'); @@ -222,7 +222,7 @@ public function testCreateFromFormatWithTimezoneString() public function testCreateFromFormatWithTimezoneObject() { - $tz = new \DateTimeZone('Europe/London'); + $tz = new DateTimeZone('Europe/London'); $time = Time::createFromFormat('F j, Y', 'January 15, 2017', $tz); @@ -422,7 +422,7 @@ public function testGetTimezone() { $instance = Time::now()->getTimezone(); - $this->assertInstanceOf(\DateTimeZone::class, $instance); + $this->assertInstanceOf(DateTimeZone::class, $instance); } public function testGetTimezonename() @@ -776,7 +776,7 @@ public function testEqualWithSame() public function testEqualWithDateTime() { $time1 = Time::parse('January 10, 2017 21:50:00', 'America/Chicago'); - $time2 = new \DateTime('January 11, 2017 03:50:00', new \DateTimeZone('Europe/London')); + $time2 = new DateTime('January 11, 2017 03:50:00', new DateTimeZone('Europe/London')); $this->assertTrue($time1->equals($time2)); } @@ -784,7 +784,7 @@ public function testEqualWithDateTime() public function testEqualWithSameDateTime() { $time1 = Time::parse('January 10, 2017 21:50:00', 'America/Chicago'); - $time2 = new \DateTime('January 10, 2017 21:50:00', new \DateTimeZone('America/Chicago')); + $time2 = new DateTime('January 10, 2017 21:50:00', new DateTimeZone('America/Chicago')); $this->assertTrue($time1->equals($time2)); } diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php index 0dd8b3d69adc..fcf4beea6257 100644 --- a/tests/system/Language/LanguageTest.php +++ b/tests/system/Language/LanguageTest.php @@ -271,6 +271,23 @@ public function testBaseFallbacks() $this->assertSame('More.shootMe', $this->lang->getLine('More.shootMe')); } + /** + * Test if after using lang() with a locale the Language class keep the locale after return the $line + */ + public function testLangKeepLocale() + { + $this->lang = Services::language('en', true); + + lang('Language.languageGetLineInvalidArgumentException'); + $this->assertSame('en', $this->lang->getLocale()); + + lang('Language.languageGetLineInvalidArgumentException', [], 'ru'); + $this->assertSame('en', $this->lang->getLocale()); + + lang('Language.languageGetLineInvalidArgumentException'); + $this->assertSame('en', $this->lang->getLocale()); + } + /** * Testing base locale vs variants, with fallback to English. * diff --git a/tests/system/Models/AffectedRowsTest.php b/tests/system/Models/AffectedRowsTest.php new file mode 100644 index 000000000000..c15d84be341d --- /dev/null +++ b/tests/system/Models/AffectedRowsTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +final class AffectedRowsTest extends LiveModelTestCase +{ + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/5137 + */ + public function testAffectedRowsWithEmptyUpdate(): void + { + $this->createModel(UserModel::class); + $notExistsId = -1; + $this->model + ->set('country', 'US') + ->where('id', $notExistsId) + ->update(); + + $this->assertSame(0, $this->model->affectedRows()); + } +} diff --git a/tests/system/Models/InsertModelTest.php b/tests/system/Models/InsertModelTest.php index 38b86eb67e30..521dec8fbadd 100644 --- a/tests/system/Models/InsertModelTest.php +++ b/tests/system/Models/InsertModelTest.php @@ -165,7 +165,6 @@ public function testInsertBatchNewEntityWithDateTime(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ @@ -223,7 +222,6 @@ public function testInsertEntityWithNoDataExceptionNoAllowedData(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ diff --git a/tests/system/Models/PaginateModelTest.php b/tests/system/Models/PaginateModelTest.php index d5901bd702c4..35e58221ecee 100644 --- a/tests/system/Models/PaginateModelTest.php +++ b/tests/system/Models/PaginateModelTest.php @@ -70,4 +70,13 @@ public function testPaginateWithoutDeleted(): void $this->assertCount(3, $data); $this->assertSame(3, $this->model->pager->getDetails()['total']); } + + public function testPaginatePageOutOfRange(): void + { + $this->createModel(ValidModel::class); + $this->model->paginate(1, 'default', -500); + $this->assertSame(1, $this->model->pager->getCurrentPage()); + $this->model->paginate(1, 'default', 500); + $this->assertSame($this->model->pager->getPageCount(), $this->model->pager->getCurrentPage()); + } } diff --git a/tests/system/Models/ReplaceModelTest.php b/tests/system/Models/ReplaceModelTest.php new file mode 100644 index 000000000000..cfa5b8af8573 --- /dev/null +++ b/tests/system/Models/ReplaceModelTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +final class ReplaceModelTest extends LiveModelTestCase +{ + public function testReplaceRespectsUseTimestamps(): void + { + $this->createModel(UserModel::class); + + $data = [ + 'name' => 'Amanda Holmes', + 'email' => 'amanda@holmes.com', + 'country' => 'US', + ]; + + $id = $this->model->insert($data); + + $data['id'] = $id; + $data['country'] = 'UK'; + + $sql = $this->model->replace($data, true); + $this->assertStringNotContainsString('updated_at', $sql); + + $this->model = $this->createModel(UserModel::class); + $this->setPrivateProperty($this->model, 'useTimestamps', true); + $sql = $this->model->replace($data, true); + $this->assertStringContainsString('updated_at', $sql); + } +} diff --git a/tests/system/Models/SaveModelTest.php b/tests/system/Models/SaveModelTest.php index 17f56a4cb88c..48b63c14b211 100644 --- a/tests/system/Models/SaveModelTest.php +++ b/tests/system/Models/SaveModelTest.php @@ -216,7 +216,6 @@ public function testSaveNewEntityWithDateTime(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 619028a748aa..888c019eca1e 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -166,7 +166,6 @@ public function testUpdateBatchWithEntity(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ @@ -186,7 +185,6 @@ public function testUpdateBatchWithEntity(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ @@ -315,7 +313,6 @@ public function testUpdateWithEntityNoAllowedFields(): void protected $deleted; protected $created_at; protected $updated_at; - protected $_options = [ 'datamap' => [], 'dates' => [ diff --git a/tests/system/Pager/PagerTest.php b/tests/system/Pager/PagerTest.php index 72deb09c2c4c..05b069d83c21 100644 --- a/tests/system/Pager/PagerTest.php +++ b/tests/system/Pager/PagerTest.php @@ -29,6 +29,7 @@ final class PagerTest extends CIUnitTestCase * @var \CodeIgniter\Pager\Pager */ protected $pager; + protected $config; protected function setUp(): void diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php new file mode 100644 index 000000000000..a6f6b8eec582 --- /dev/null +++ b/tests/system/Publisher/PublisherInputTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Publisher\Publisher; +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +final class PublisherInputTest extends CIUnitTestCase +{ + /** + * A known, valid file + * + * @var string + */ + private $file = SUPPORTPATH . 'Files/baker/banana.php'; + + /** + * A known, valid directory + * + * @var string + */ + private $directory = SUPPORTPATH . 'Files/able/'; + + /** + * Initialize the helper, since some + * tests call static methods before + * the constructor would load it. + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper(['filesystem']); + } + + public function testAddPathFile() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php'); + + $this->assertSame([$this->file], $publisher->get()); + } + + public function testAddPathFileRecursiveDoesNothing() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php', true); + + $this->assertSame([$this->file], $publisher->get()); + } + + public function testAddPathDirectory() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->addPath('able'); + + $this->assertSame($expected, $publisher->get()); + } + + public function testAddPathDirectoryRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPath('Files'); + + $this->assertSame($expected, $publisher->get()); + } + + public function testAddPaths() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPaths([ + 'able', + 'baker/banana.php', + ]); + + $this->assertSame($expected, $publisher->get()); + } + + public function testAddPathsRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $publisher->addPaths([ + 'Files', + 'Log', + ], true); + + $this->assertSame($expected, $publisher->get()); + } + + public function testAddUri() + { + $publisher = new Publisher(); + $publisher->addUri('https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json'); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'composer.json'], $publisher->get()); + } + + public function testAddUris() + { + $publisher = new Publisher(); + $publisher->addUris([ + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/LICENSE', + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json', + ]); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'LICENSE', $scratch . 'composer.json'], $publisher->get()); + } +} diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php new file mode 100644 index 000000000000..512e051f7663 --- /dev/null +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Publisher\Publisher; +use CodeIgniter\Test\CIUnitTestCase; +use org\bovigo\vfs\vfsStream; +use org\bovigo\vfs\vfsStreamDirectory; + +/** + * @internal + */ +final class PublisherOutputTest extends CIUnitTestCase +{ + /** + * Files to seed to VFS + * + * @var array + */ + private $structure; + + /** + * Virtual destination + * + * @var vfsStreamDirectory + */ + private $root; + + /** + * A known, valid file + * + * @var string + */ + private $file = SUPPORTPATH . 'Files/baker/banana.php'; + + /** + * A known, valid directory + * + * @var string + */ + private $directory = SUPPORTPATH . 'Files/able/'; + + /** + * Initialize the helper, since some + * tests call static methods before + * the constructor would load it. + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper(['filesystem']); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->structure = [ + 'able' => [ + 'apple.php' => 'Once upon a midnight dreary', + 'bazam' => 'While I pondered weak and weary', + ], + 'boo' => [ + 'far' => 'Upon a tome of long-forgotten lore', + 'faz' => 'There came a tapping up on the door', + ], + 'AnEmptyFolder' => [], + 'simpleFile' => 'A tap-tap-tapping upon my door', + '.hidden' => 'There is no spoon', + ]; + + $this->root = vfsStream::setup('root', null, $this->structure); + + // Add root to the list of allowed destinations + config('Publisher')->restrictions[$this->root->url()] = '*'; + } + + public function testCopy() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + $this->assertFileDoesNotExist($this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/banana.php'); + } + + public function testCopyReplace() + { + $file = $this->directory . 'apple.php'; + $publisher = new Publisher($this->directory, $this->root->url() . '/able'); + $publisher->addFile($file); + + $this->assertFileExists($this->root->url() . '/able/apple.php'); + $this->assertFalse(same_file($file, $this->root->url() . '/able/apple.php')); + + $result = $publisher->copy(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($file, $this->root->url() . '/able/apple.php')); + } + + public function testCopyIgnoresSame() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + copy($this->file, $this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + $this->assertTrue($result); + + $result = $publisher->copy(true); + $this->assertTrue($result); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); + } + + public function testCopyIgnoresCollision() + { + $publisher = new Publisher($this->directory, $this->root->url()); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(false); + + $this->assertTrue($result); + $this->assertSame([], $publisher->getErrors()); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); + } + + public function testCopyCollides() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->file, $this->root->url() . '/banana.php']); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame([], $publisher->getPublished()); + $this->assertSame($expected, $errors[$this->file]->getMessage()); + } + + public function testMerge() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; + + $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); + + $result = $publisher->addPath('/')->merge(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertSame($expected, $publisher->getPublished()); + } + + public function testMergeReplace() + { + $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; + + $result = $publisher->addPath('/')->merge(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $this->assertSame($expected, $publisher->getPublished()); + } + + public function testMergeCollides() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + $published = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; + + mkdir($this->root->url() . '/able/fig_3.php'); + + $result = $publisher->addPath('/')->merge(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($published, $publisher->getPublished()); + $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); + } + + public function testPublish() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $result = $publisher->publish(); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + } +} diff --git a/tests/system/Publisher/PublisherRestrictionsTest.php b/tests/system/Publisher/PublisherRestrictionsTest.php new file mode 100644 index 000000000000..692e49d7680b --- /dev/null +++ b/tests/system/Publisher/PublisherRestrictionsTest.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Publisher\Exceptions\PublisherException; +use CodeIgniter\Publisher\Publisher; +use CodeIgniter\Test\CIUnitTestCase; + +/** + * Publisher Restrictions Test + * + * Tests that the restrictions defined in the configuration + * file properly prevent disallowed actions. + * + * @internal + */ +final class PublisherRestrictionsTest extends CIUnitTestCase +{ + /** + * @see Tests\Support\Config\Registrars::Publisher() + */ + public function testRegistrarsNotAllowed() + { + $this->assertArrayNotHasKey(SUPPORTPATH, config('Publisher')->restrictions); + } + + public function testImmutableRestrictions() + { + $publisher = new Publisher(); + + // Try to "hack" the Publisher by adding our desired destination to the config + config('Publisher')->restrictions[SUPPORTPATH] = '*'; + + $restrictions = $this->getPrivateProperty($publisher, 'restrictions'); + + $this->assertArrayNotHasKey(SUPPORTPATH, $restrictions); + } + + /** + * @dataProvider fileProvider + */ + public function testDefaultPublicRestrictions(string $path) + { + $publisher = new Publisher(ROOTPATH, FCPATH); + $pattern = config('Publisher')->restrictions[FCPATH]; + + // Use the scratch space to create a file + $file = $publisher->getScratch() . $path; + file_put_contents($file, 'To infinity and beyond!'); + + $result = $publisher->addFile($file)->merge(); + $this->assertFalse($result); + + $errors = $publisher->getErrors(); + $this->assertCount(1, $errors); + $this->assertSame([$file], array_keys($errors)); + + $expected = lang('Publisher.fileNotAllowed', [$file, FCPATH, $pattern]); + $this->assertSame($expected, $errors[$file]->getMessage()); + } + + public function fileProvider() + { + yield 'php' => ['index.php']; + + yield 'exe' => ['cat.exe']; + + yield 'flat' => ['banana']; + } + + /** + * @dataProvider destinationProvider + */ + public function testDestinations(string $destination, bool $allowed) + { + config('Publisher')->restrictions = [ + APPPATH => '', + FCPATH => '', + SUPPORTPATH . 'Files' => '', + SUPPORTPATH . 'Files/../' => '', + ]; + + if (! $allowed) { + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.destinationNotAllowed', [$destination])); + } + + $publisher = new Publisher(null, $destination); + $this->assertInstanceOf(Publisher::class, $publisher); + } + + public function destinationProvider() + { + return [ + 'explicit' => [ + APPPATH, + true, + ], + 'subdirectory' => [ + APPPATH . 'Config', + true, + ], + 'relative' => [ + SUPPORTPATH . 'Files/able/../', + true, + ], + 'parent' => [ + SUPPORTPATH, + false, + ], + ]; + } +} diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php new file mode 100644 index 000000000000..de1fb29fbd93 --- /dev/null +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Publisher\Exceptions\PublisherException; +use CodeIgniter\Publisher\Publisher; +use CodeIgniter\Test\CIUnitTestCase; +use Tests\Support\Publishers\TestPublisher; + +/** + * @internal + */ +final class PublisherSupportTest extends CIUnitTestCase +{ + /** + * A known, valid file + * + * @var string + */ + private $file = SUPPORTPATH . 'Files/baker/banana.php'; + + /** + * A known, valid directory + * + * @var string + */ + private $directory = SUPPORTPATH . 'Files/able/'; + + /** + * Initialize the helper, since some + * tests call static methods before + * the constructor would load it. + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper(['filesystem']); + } + + public function testDiscoverDefault() + { + $result = Publisher::discover(); + + $this->assertCount(1, $result); + $this->assertInstanceOf(TestPublisher::class, $result[0]); + } + + public function testDiscoverNothing() + { + $result = Publisher::discover('Nothing'); + + $this->assertSame([], $result); + } + + public function testDiscoverStores() + { + $publisher = Publisher::discover()[0]; + $publisher->set([])->addFile($this->file); + + $result = Publisher::discover(); + $this->assertSame($publisher, $result[0]); + $this->assertSame([$this->file], $result[0]->get()); + } + + public function testGetSource() + { + $publisher = new Publisher(ROOTPATH); + + $this->assertSame(ROOTPATH, $publisher->getSource()); + } + + public function testGetDestination() + { + $publisher = new Publisher(ROOTPATH, SUPPORTPATH); + + $this->assertSame(SUPPORTPATH, $publisher->getDestination()); + } + + public function testGetScratch() + { + $publisher = new Publisher(); + $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); + + $scratch = $publisher->getScratch(); + + $this->assertIsString($scratch); + $this->assertDirectoryExists($scratch); + $this->assertDirectoryIsWritable($scratch); + $this->assertNotNull($this->getPrivateProperty($publisher, 'scratch')); + + // Directory and contents should be removed on __destruct() + $file = $scratch . 'obvious_statement.txt'; + file_put_contents($file, 'Bananas are a most peculiar fruit'); + + $publisher->__destruct(); + + $this->assertFileDoesNotExist($file); + $this->assertDirectoryDoesNotExist($scratch); + } + + public function testGetErrors() + { + $publisher = new Publisher(); + $this->assertSame([], $publisher->getErrors()); + + $expected = [ + $this->file => PublisherException::forCollision($this->file, $this->file), + ]; + + $this->setPrivateProperty($publisher, 'errors', $expected); + + $this->assertSame($expected, $publisher->getErrors()); + } + + public function testWipeDirectory() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $this->assertDirectoryExists($directory); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($directory); + + $this->assertDirectoryDoesNotExist($directory); + } + + public function testWipeIgnoresFiles() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($this->file); + + $this->assertFileExists($this->file); + } + + public function testWipe() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $directory = realpath($directory) ?: $directory; + $this->assertDirectoryExists($directory); + config('Publisher')->restrictions[$directory] = ''; // Allow the directory + + $publisher = new Publisher($this->directory, $directory); + $publisher->wipe(); + + $this->assertDirectoryDoesNotExist($directory); + } +} diff --git a/tests/system/RESTful/ResourceControllerTest.php b/tests/system/RESTful/ResourceControllerTest.php index 5f2e58994d10..ec8d13af7a2d 100644 --- a/tests/system/RESTful/ResourceControllerTest.php +++ b/tests/system/RESTful/ResourceControllerTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Router\RouteCollection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\Mock\MockResourceController; @@ -45,7 +46,7 @@ final class ResourceControllerTest extends CIUnitTestCase protected $codeigniter; /** - * @var \CodeIgniter\Router\RoutesCollection + * @var RouteCollection */ protected $routes; @@ -53,7 +54,7 @@ protected function setUp(): void { parent::setUp(); - Services::reset(); + $this->resetServices(); $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; diff --git a/tests/system/RESTful/ResourcePresenterTest.php b/tests/system/RESTful/ResourcePresenterTest.php index 2f0a332a57f9..26ea1084b196 100644 --- a/tests/system/RESTful/ResourcePresenterTest.php +++ b/tests/system/RESTful/ResourcePresenterTest.php @@ -13,6 +13,7 @@ use CodeIgniter\CodeIgniter; use CodeIgniter\Config\Services; +use CodeIgniter\Router\RouteCollection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\Mock\MockResourcePresenter; @@ -39,7 +40,7 @@ final class ResourcePresenterTest extends CIUnitTestCase protected $codeigniter; /** - * @var \CodeIgniter\Router\RoutesCollection + * @var RouteCollection */ protected $routes; @@ -47,7 +48,7 @@ protected function setUp(): void { parent::setUp(); - Services::reset(); + $this->resetServices(); $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php index 82ea515f600b..0c264fbdc43b 100644 --- a/tests/system/Router/RouterTest.php +++ b/tests/system/Router/RouterTest.php @@ -16,6 +16,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use Tests\Support\Filters\Customfilter; /** * @internal @@ -103,6 +104,16 @@ public function testURIMapsToController() $this->assertSame('index', $router->methodName()); } + public function testURIWithTrailingSlashMapsToController() + { + $router = new Router($this->collection, $this->request); + + $router->handle('users/'); + + $this->assertSame('\Users', $router->controllerName()); + $this->assertSame('index', $router->methodName()); + } + public function testURIMapsToControllerAltMethod() { $router = new Router($this->collection, $this->request); @@ -166,6 +177,16 @@ public function testURIMapsParamsWithMany() $this->assertSame(['123', 'abc', 'FOO'], $router->params()); } + public function testURIWithTrailingSlashMapsParamsWithMany() + { + $router = new Router($this->collection, $this->request); + + $router->handle('objects/123/sort/abc/FOO/'); + + $this->assertSame('objectsSortCreate', $router->methodName()); + $this->assertSame(['123', 'abc', 'FOO'], $router->params()); + } + public function testClosures() { $router = new Router($this->collection, $this->request); @@ -509,6 +530,39 @@ static function (RouteCollection $routes) { $this->assertSame('api-auth', $router->getFilter()); } + public function testRouteWorksWithClassnameFilter() + { + $collection = $this->collection; + + $collection->add('foo', 'TestController::foo', ['filter' => Customfilter::class]); + $router = new Router($collection, $this->request); + + $router->handle('foo'); + + $this->assertSame('\TestController', $router->controllerName()); + $this->assertSame('foo', $router->methodName()); + $this->assertSame('Tests\Support\Filters\Customfilter', $router->getFilter()); + } + + public function testRouteWorksWithMultipleFilters() + { + $feature = config('Feature'); + $feature->multipleFilters = true; + + $collection = $this->collection; + + $collection->add('foo', 'TestController::foo', ['filter' => ['filter1', 'filter2:param']]); + $router = new Router($collection, $this->request); + + $router->handle('foo'); + + $this->assertSame('\TestController', $router->controllerName()); + $this->assertSame('foo', $router->methodName()); + $this->assertSame(['filter1', 'filter2:param'], $router->getFilters()); + + $feature->multipleFilters = false; + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/1240 */ diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php new file mode 100644 index 000000000000..c0ec2c066c40 --- /dev/null +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -0,0 +1,251 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Security; + +use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Session\Handlers\ArrayHandler; +use CodeIgniter\Session\Session; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use CodeIgniter\Test\Mock\MockSession; +use CodeIgniter\Test\TestLogger; +use Config\App as AppConfig; +use Config\Logger as LoggerConfig; +use Config\Security as SecurityConfig; + +/** + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + * + * @internal + */ +final class SecurityCSRFSessionTest extends CIUnitTestCase +{ + /** + * @var string CSRF protection hash + */ + private $hash = '8b9218a55906f9dcc1dc263dce7f005a'; + + protected function setUp(): void + { + parent::setUp(); + + $_SESSION = []; + Factories::reset(); + + $config = new SecurityConfig(); + $config->csrfProtection = Security::CSRF_PROTECTION_SESSION; + Factories::injectMock('config', 'Security', $config); + + $this->injectSession($this->hash); + } + + private function createSession($options = []): Session + { + $defaults = [ + 'sessionDriver' => 'CodeIgniter\Session\Handlers\FileHandler', + 'sessionCookieName' => 'ci_session', + 'sessionExpiration' => 7200, + 'sessionSavePath' => null, + 'sessionMatchIP' => false, + 'sessionTimeToUpdate' => 300, + 'sessionRegenerateDestroy' => false, + 'cookieDomain' => '', + 'cookiePrefix' => '', + 'cookiePath' => '/', + 'cookieSecure' => false, + 'cookieSameSite' => 'Lax', + ]; + + $config = array_merge($defaults, $options); + $appConfig = new AppConfig(); + + foreach ($config as $key => $c) { + $appConfig->{$key} = $c; + } + + $session = new MockSession(new ArrayHandler($appConfig, '127.0.0.1'), $appConfig); + $session->setLogger(new TestLogger(new LoggerConfig())); + + return $session; + } + + private function injectSession(string $hash): void + { + $session = $this->createSession(); + $session->set('csrf_test_name', $hash); + Services::injectMock('session', $session); + } + + public function testHashIsReadFromSession() + { + $security = new Security(new MockAppConfig()); + + $this->assertSame($this->hash, $security->getHash()); + } + + public function testCSRFVerifyPostThrowsExceptionOnNoMatch() + { + $this->expectException(SecurityException::class); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $security->verify($request); + } + + public function testCSRFVerifyPostReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['foo'] = 'bar'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertTrue(count($_POST) === 1); + } + + public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); + + $security = new Security(new MockAppConfig()); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['foo'] = 'bar'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertCount(1, $_POST); + } + + public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch() + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); + + $security = new Security(new MockAppConfig()); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + } + + public function testCSRFVerifyJsonThrowsExceptionOnNoMatch() + { + $this->expectException(SecurityException::class); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); + + $security = new Security(new MockAppConfig()); + + $security->verify($request); + } + + public function testCSRFVerifyJsonReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertTrue($request->getBody() === '{"foo":"bar"}'); + } + + public function testRegenerateWithFalseSecurityRegenerateProperty() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $config = Factories::config('Security'); + $config->regenerate = false; + Factories::injectMock('config', 'Security', $config); + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $oldHash = $security->getHash(); + $security->verify($request); + $newHash = $security->getHash(); + + $this->assertSame($oldHash, $newHash); + } + + public function testRegenerateWithTrueSecurityRegenerateProperty() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $config = Factories::config('Security'); + $config->regenerate = true; + Factories::injectMock('config', 'Security', $config); + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $oldHash = $security->getHash(); + $security->verify($request); + $newHash = $security->getHash(); + + $this->assertNotSame($oldHash, $newHash); + } +} diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index aadb84aa775b..ba6a4ada5024 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -41,7 +41,11 @@ protected function setUp(): void public function testBasicConfigIsSaved() { - $security = new Security(new MockAppConfig()); + $config = new MockAppConfig(); + $security = $this->getMockBuilder(Security::class) + ->setConstructorArgs([$config]) + ->onlyMethods(['doSendCookie']) + ->getMock(); $hash = $security->getHash(); @@ -53,12 +57,16 @@ public function testHashIsReadFromCookie() { $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new Security(new MockAppConfig()); + $config = new MockAppConfig(); + $security = $this->getMockBuilder(Security::class) + ->setConstructorArgs([$config]) + ->onlyMethods(['doSendCookie']) + ->getMock(); $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $security->getHash()); } - public function testCSRFVerifySetsCookieWhenNotPOST() + public function testGetHashSetsCookieWhenGETWithoutCSRFCookie() { $security = new MockSecurity(new MockAppConfig()); @@ -69,29 +77,41 @@ public function testCSRFVerifySetsCookieWhenNotPOST() $this->assertSame($_COOKIE['csrf_cookie_name'], $security->getHash()); } - public function testCSRFVerifyPostThrowsExceptionOnNoMatch() + public function testGetHashReturnsCSRFCookieWhenGETWithCSRFCookie() { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $security = new MockSecurity(new MockAppConfig()); - $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $security->verify(new Request(new MockAppConfig())); + + $this->assertSame($_COOKIE['csrf_cookie_name'], $security->getHash()); + } + + public function testCSRFVerifyPostThrowsExceptionOnNoMatch() + { $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + $security = new MockSecurity(new MockAppConfig()); + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $this->expectException(SecurityException::class); $security->verify($request); } public function testCSRFVerifyPostReturnsSelfOnMatch() { - $security = new MockSecurity(new MockAppConfig()); - $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['foo'] = 'bar'; $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $security = new MockSecurity(new MockAppConfig()); + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -100,29 +120,29 @@ public function testCSRFVerifyPostReturnsSelfOnMatch() public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; - $this->expectException(SecurityException::class); $security->verify($request); } public function testCSRFVerifyHeaderReturnsSelfOnMatch() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['foo'] = 'bar'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -131,28 +151,28 @@ public function testCSRFVerifyHeaderReturnsSelfOnMatch() public function testCSRFVerifyJsonThrowsExceptionOnNoMatch() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a"}'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; - $this->expectException(SecurityException::class); $security->verify($request); } public function testCSRFVerifyJsonReturnsSelfOnMatch() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -170,6 +190,10 @@ public function testSanitizeFilename() public function testRegenerateWithFalseSecurityRegenerateProperty() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $config = new SecurityConfig(); $config->regenerate = false; Factories::injectMock('config', 'Security', $config); @@ -177,10 +201,6 @@ public function testRegenerateWithFalseSecurityRegenerateProperty() $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $oldHash = $security->getHash(); $security->verify($request); $newHash = $security->getHash(); @@ -190,6 +210,10 @@ public function testRegenerateWithFalseSecurityRegenerateProperty() public function testRegenerateWithTrueSecurityRegenerateProperty() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + $config = new SecurityConfig(); $config->regenerate = true; Factories::injectMock('config', 'Security', $config); @@ -197,10 +221,6 @@ public function testRegenerateWithTrueSecurityRegenerateProperty() $security = new MockSecurity(new MockAppConfig()); $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $oldHash = $security->getHash(); $security->verify($request); $newHash = $security->getHash(); diff --git a/tests/system/Session/Handlers/DatabaseHandlerTest.php b/tests/system/Session/Handlers/DatabaseHandlerTest.php new file mode 100644 index 000000000000..2349bc292bd8 --- /dev/null +++ b/tests/system/Session/Handlers/DatabaseHandlerTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Session\Handlers; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use CodeIgniter\Test\ReflectionHelper; +use Config\App as AppConfig; +use Config\Database as DatabaseConfig; + +/** + * @internal + */ +final class DatabaseHandlerTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + use ReflectionHelper; + + protected $refresh = true; + protected $seed = 'Tests\Support\Database\Seeds\CITestSeeder'; + + protected function setUp(): void + { + parent::setUp(); + + if (! in_array(config(DatabaseConfig::class)->tests['DBDriver'], ['MySQLi', 'Postgre'], true)) { + $this->markTestSkipped('Database Session Handler requires database driver to be MySQLi or Postgre'); + } + } + + protected function getInstance($options = []) + { + $defaults = [ + 'sessionDriver' => 'CodeIgniter\Session\Handlers\DatabaseHandler', + 'sessionCookieName' => 'ci_session', + 'sessionExpiration' => 7200, + 'sessionSavePath' => 'ci_sessions', + 'sessionMatchIP' => false, + 'sessionTimeToUpdate' => 300, + 'sessionRegenerateDestroy' => false, + 'cookieDomain' => '', + 'cookiePrefix' => '', + 'cookiePath' => '/', + 'cookieSecure' => false, + 'cookieSameSite' => 'Lax', + ]; + + $config = array_merge($defaults, $options); + $appConfig = new AppConfig(); + + foreach ($config as $key => $c) { + $appConfig->{$key} = $c; + } + + return new DatabaseHandler($appConfig, '127.0.0.1'); + } + + public function testOpen() + { + $handler = $this->getInstance(); + $this->assertTrue($handler->open('ci_sessions', 'ci_session')); + } + + public function testReadSuccess() + { + $handler = $this->getInstance(); + $expected = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertSame($expected, $handler->read('1f5o06b43phsnnf8if6bo33b635e4p2o')); + + $this->assertTrue($this->getPrivateProperty($handler, 'rowExists')); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testReadFailure() + { + $handler = $this->getInstance(); + $this->assertSame('', $handler->read('123456b43phsnnf8if6bo33b635e4321')); + + $this->assertFalse($this->getPrivateProperty($handler, 'rowExists')); + $this->assertSame('d41d8cd98f00b204e9800998ecf8427e', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testWriteInsert() + { + $handler = $this->getInstance(); + + $this->setPrivateProperty($handler, 'lock', true); + + $data = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertTrue($handler->write('555556b43phsnnf8if6bo33b635e4444', $data)); + + $this->setPrivateProperty($handler, 'lock', false); + + $row = $this->db->table('ci_sessions') + ->getWhere(['id' => '555556b43phsnnf8if6bo33b635e4444']) + ->getRow(); + + $this->assertGreaterThan(time() - 100, strtotime($row->timestamp)); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testWriteUpdate() + { + $handler = $this->getInstance(); + + $this->setPrivateProperty($handler, 'sessionID', '1f5o06b43phsnnf8if6bo33b635e4p2o'); + $this->setPrivateProperty($handler, 'rowExists', true); + + $lockSession = $this->getPrivateMethodInvoker($handler, 'lockSession'); + $lockSession('1f5o06b43phsnnf8if6bo33b635e4p2o'); + + $data = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertTrue($handler->write('1f5o06b43phsnnf8if6bo33b635e4p2o', $data)); + + $releaseLock = $this->getPrivateMethodInvoker($handler, 'releaseLock'); + $releaseLock(); + + $row = $this->db->table('ci_sessions') + ->getWhere(['id' => '1f5o06b43phsnnf8if6bo33b635e4p2o']) + ->getRow(); + + $this->assertGreaterThan(time() - 100, strtotime($row->timestamp)); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testGC() + { + $handler = $this->getInstance(); + $this->assertSame(1, $handler->gc(3600)); + } +} diff --git a/tests/system/Validation/CreditCardRulesTest.php b/tests/system/Validation/CreditCardRulesTest.php index a1e8f74d8a27..7610ea4ee966 100644 --- a/tests/system/Validation/CreditCardRulesTest.php +++ b/tests/system/Validation/CreditCardRulesTest.php @@ -24,6 +24,7 @@ final class CreditCardRulesTest extends CIUnitTestCase * @var Validation */ protected $validation; + protected $config = [ 'ruleSets' => [ Rules::class, diff --git a/tests/system/Validation/FileRulesTest.php b/tests/system/Validation/FileRulesTest.php index 0e4e70ec537f..e8da364367a7 100644 --- a/tests/system/Validation/FileRulesTest.php +++ b/tests/system/Validation/FileRulesTest.php @@ -24,6 +24,7 @@ final class FileRulesTest extends CIUnitTestCase * @var Validation */ protected $validation; + protected $config = [ 'ruleSets' => [ Rules::class, diff --git a/tests/system/Validation/FormatRulesTest.php b/tests/system/Validation/FormatRulesTest.php index 725824607748..76e613604398 100644 --- a/tests/system/Validation/FormatRulesTest.php +++ b/tests/system/Validation/FormatRulesTest.php @@ -27,6 +27,7 @@ final class FormatRulesTest extends CIUnitTestCase * @var Validation */ protected $validation; + protected $config = [ 'ruleSets' => [ Rules::class, @@ -87,7 +88,7 @@ public function testRegexMatchFalse() /** * @dataProvider urlProvider */ - public function testValidURL(?string $url, bool $expected) + public function testValidURL(?string $url, bool $isLoose, bool $isStrict) { $data = [ 'foo' => $url, @@ -97,7 +98,36 @@ public function testValidURL(?string $url, bool $expected) 'foo' => 'valid_url', ]); - $this->assertSame($expected, $this->validation->run($data)); + $this->assertSame($isLoose, $this->validation->run($data)); + } + + /** + * @dataProvider urlProvider + */ + public function testValidURLStrict(?string $url, bool $isLoose, bool $isStrict) + { + $data = [ + 'foo' => $url, + ]; + + $this->validation->setRules([ + 'foo' => 'valid_url_strict', + ]); + + $this->assertSame($isStrict, $this->validation->run($data)); + } + + public function testValidURLStrictWithSchema() + { + $data = [ + 'foo' => 'http://www.codeigniter.com', + ]; + + $this->validation->setRules([ + 'foo' => 'valid_url_strict[https]', + ]); + + $this->assertFalse($this->validation->run($data)); } public function urlProvider() @@ -106,60 +136,90 @@ public function urlProvider() [ 'www.codeigniter.com', true, + false, ], [ 'http://codeigniter.com', true, + true, ], - //https://bugs.php.net/bug.php?id=51192 + // https://bugs.php.net/bug.php?id=51192 [ 'http://accept-dashes.tld', true, + true, ], [ 'http://reject_underscores', false, + false, ], - // https://github.com/codeigniter4/CodeIgniter/issues/4415 + // https://github.com/bcit-ci/CodeIgniter/issues/4415 [ 'http://[::1]/ipv6', true, + true, ], [ 'htt://www.codeigniter.com', false, + false, ], [ '', false, + false, + ], + // https://github.com/codeigniter4/CodeIgniter4/issues/3156 + [ + 'codeigniter', + true, // What? + false, ], [ 'code igniter', false, + false, ], [ null, false, + false, ], [ 'http://', - true, - ], // this is apparently valid! + true, // Why? + false, + ], [ 'http:///oops.com', false, + false, ], [ '123.com', true, + false, ], [ 'abc.123', true, + false, ], [ 'http:8080//abc.com', + true, // Insane? + false, + ], + [ + 'mailto:support@codeigniter.com', true, + false, + ], + [ + '//example.com', + false, + false, ], ]; } diff --git a/tests/system/Validation/RulesTest.php b/tests/system/Validation/RulesTest.php index f4f171b87bbb..7fe8469fcbeb 100644 --- a/tests/system/Validation/RulesTest.php +++ b/tests/system/Validation/RulesTest.php @@ -31,6 +31,7 @@ final class RulesTest extends CIUnitTestCase * @var Validation */ protected $validation; + protected $config = [ 'ruleSets' => [ Rules::class, diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 836b892a2931..8b77686fc3b5 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -29,6 +29,7 @@ final class ValidationTest extends CIUnitTestCase * @var Validation */ protected $validation; + protected $config = [ 'ruleSets' => [ Rules::class, @@ -628,6 +629,30 @@ public function testTagReplacement() $this->assertSame($expected, $errors['Username']); } + public function testRulesForObjectField() + { + $this->validation->setRules([ + 'configuration' => 'required|check_object_rule', + ]); + + $data = (object) ['configuration' => (object) ['first' => 1, 'second' => 2]]; + $this->validation->run((array) $data); + $this->assertSame([], $this->validation->getErrors()); + + $this->validation->reset(); + + $this->validation->setRules([ + 'configuration' => 'required|check_object_rule', + ]); + + $data = (object) ['configuration' => (object) ['first1' => 1, 'second' => 2]]; + $this->validation->run((array) $data); + + $this->assertSame([ + 'configuration' => 'Validation.check_object_rule', + ], $this->validation->getErrors()); + } + /** * @dataProvider arrayFieldDataProvider * @@ -984,4 +1009,63 @@ public function validationArrayDataCaseProvider(): iterable ]], ]; } + + /** + * @dataProvider provideStringRulesCases + * + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4929 + */ + public function testSplittingOfComplexStringRules(string $input, array $expected): void + { + $splitter = $this->getPrivateMethodInvoker($this->validation, 'splitRules'); + $this->assertSame($expected, $splitter($input)); + } + + public function provideStringRulesCases(): iterable + { + yield [ + 'required', + ['required'], + ]; + + yield [ + 'required|numeric', + ['required', 'numeric'], + ]; + + yield [ + 'required|max_length[500]|hex', + ['required', 'max_length[500]', 'hex'], + ]; + + yield [ + 'required|numeric|regex_match[/[a-zA-Z]+/]', + ['required', 'numeric', 'regex_match[/[a-zA-Z]+/]'], + ]; + + yield [ + 'required|max_length[500]|regex_match[/^;"\'{}\[\]^<>=/]', + ['required', 'max_length[500]', 'regex_match[/^;"\'{}\[\]^<>=/]'], + ]; + + yield [ + 'regex_match[/^;"\'{}\[\]^<>=/]|regex_match[/[^a-z0-9.\|_]+/]', + ['regex_match[/^;"\'{}\[\]^<>=/]', 'regex_match[/[^a-z0-9.\|_]+/]'], + ]; + + yield [ + 'required|regex_match[/^(01[2689]|09)[0-9]{8}$/]|numeric', + ['required', 'regex_match[/^(01[2689]|09)[0-9]{8}$/]', 'numeric'], + ]; + + yield [ + 'required|regex_match[/^[0-9]{4}[\-\.\[\/][0-9]{2}[\-\.\[\/][0-9]{2}/]|max_length[10]', + ['required', 'regex_match[/^[0-9]{4}[\-\.\[\/][0-9]{2}[\-\.\[\/][0-9]{2}/]', 'max_length[10]'], + ]; + + yield [ + 'required|regex_match[/^(01|2689|09)[0-9]{8}$/]|numeric', + ['required', 'regex_match[/^(01|2689|09)[0-9]{8}$/]', 'numeric'], + ]; + } } diff --git a/tests/system/View/ParserPluginTest.php b/tests/system/View/ParserPluginTest.php index df773a375df8..85de3e7898fa 100644 --- a/tests/system/View/ParserPluginTest.php +++ b/tests/system/View/ParserPluginTest.php @@ -24,6 +24,7 @@ final class ParserPluginTest extends CIUnitTestCase * @var Parser */ protected $parser; + /** * @var Validation */ diff --git a/user_guide_src/README.rst b/user_guide_src/README.rst index 7388e1104428..777ebb180530 100644 --- a/user_guide_src/README.rst +++ b/user_guide_src/README.rst @@ -22,15 +22,15 @@ or ``python3``. .. code-block:: bash - python --version - Python 2.7.17 + python --version + Python 2.7.17 - python3 --version - Python 3.6.9 + python3 --version + Python 3.6.9 - # For Windows using the Python Launcher - py -3 --version - Python 3.8.1 + # For Windows using the Python Launcher + py -3 --version + Python 3.8.1 If you're not on 3.5+, go ahead and install the latest 3.x version from `Python.org `_. Linux users should use their @@ -48,15 +48,15 @@ Please take note that it should say ``python 3.x`` at the very end. .. code-block:: bash - pip --version - pip 9.0.1 from /usr/lib/python2.7/dist-packages (python 2.7) + pip --version + pip 9.0.1 from /usr/lib/python2.7/dist-packages (python 2.7) - pip3 --version - pip 9.0.1 from /usr/lib/python3/dist-packages (python 3.6) + pip3 --version + pip 9.0.1 from /usr/lib/python3/dist-packages (python 3.6) - # For Windows using the Python Launcher - py -3 -m pip --version - pip 20.0.2 from C:\Users\\AppData\Local\Programs\Python\Python38\lib\site-packages\pip (python 3.8) + # For Windows using the Python Launcher + py -3 -m pip --version + pip 20.0.2 from C:\Users\\AppData\Local\Programs\Python\Python38\lib\site-packages\pip (python 3.8) Linux ^^^^^ @@ -79,19 +79,19 @@ window as Python won't find all applications we just installed othervise. .. code-block:: bash - pip install -r user_guide_src/requirements.txt + pip install -r user_guide_src/requirements.txt - pip3 install -r user_guide_src/requirements.txt + pip3 install -r user_guide_src/requirements.txt - # For Windows using the Python Launcher - py -3 -m pip install -r user_guide_src/requirements.txt + # For Windows using the Python Launcher + py -3 -m pip install -r user_guide_src/requirements.txt It's time to wrap things up and generate the documentation. .. code-block:: bash - cd user_guide_src - make html + cd user_guide_src + make html Editing and Creating Documentation ================================== @@ -111,7 +111,7 @@ you to regenerate as necessary if you want to "preview" your work. Generating the HTML is very simple. From the root directory of your user guide repo fork issue the command you used at the end of the installation instructions:: - make html + make html You will see it do a whiz-bang compilation, at which point the fully rendered user guide and images will be in *build/html/*. After the HTML has been built, diff --git a/user_guide_src/cilexer/pycilexer.egg-info/top_level.txt b/user_guide_src/cilexer/pycilexer.egg-info/top_level.txt new file mode 100644 index 000000000000..2f88e1d75272 --- /dev/null +++ b/user_guide_src/cilexer/pycilexer.egg-info/top_level.txt @@ -0,0 +1 @@ +cilexer diff --git a/user_guide_src/ghpages.rst b/user_guide_src/ghpages.rst index 1d7719c1193c..15619a060e0d 100644 --- a/user_guide_src/ghpages.rst +++ b/user_guide_src/ghpages.rst @@ -6,7 +6,7 @@ The intent is, eventually, for the in-progress user guide to be automatically generated as part of a PR merge. This writeup explains how it can be done manually in the meantime. -The user guide takes advantage of Github pages, where the "gh-pages" branch of +The user guide takes advantage of GitHub pages, where the "gh-pages" branch of a repo, containing HTML only, is accessible through `github.io `_. @@ -26,19 +26,19 @@ Re-generating the User Guide In the ``user_guide_src`` folder, you generate a conventional user guide, for testing, using the command:: - make html + make html An additional target has been configured, which will generate the same HTML but inside the ``html`` folder of the second repo clone:: - make ghpages + make ghpages After making this target, update the online user guide by switching to the ``CodeIgniter4-guide/html`` folder, and then:: - git add . - git commit -S -m "Suitable comment" - git push origin gh-pages + git add . + git commit -S -m "Suitable comment" + git push origin gh-pages Process ======= diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 038d259e4189..2ec12f649150 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.1.5 v4.1.4 v4.1.3 v4.1.2 diff --git a/user_guide_src/source/changelogs/v4.1.5.rst b/user_guide_src/source/changelogs/v4.1.5.rst new file mode 100644 index 000000000000..3e8fb8f629f0 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.1.5.rst @@ -0,0 +1,35 @@ +Version 4.1.5 +############# + +Release Date: November 8, 2021 + +**4.1.5 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 1 + +BREAKING +======== + +- Fixed `a bug `_ on CSRF protection. Now CSRF protection works on PUT/PATCH/DELETE requests when CSRF filter is applied. If you use such requests, you need to send CSRF token. +- In the previous version, if you didn't provide your own headers, ``CURLRequest`` would send the request-headers from the browser, due to a bug. As of this version, it does not send them. +- Fixed ``BaseBuilder::insertBatch()`` return value for ``testMode``. Now it returns SQL string array instead of a number of affected rows. This change was made because of maintaining compatibility between returning types for batch methods. Now the returned data type for ``BaseBuilder::insertBatch()`` is the same as the `updateBatch()` method. +- Major optimizations have been made to the way data is processed in ``BaseBuilder::insertBatch()`` and ``BaseBuilder::updateBatch()`` methods. This resulted in reduced memory usage and faster query processing. As a trade-off, the result generated by the ``$query->getOriginalQuery()`` method was changed. It no longer returns the query with the binded parameters, but the actual query that was run. + +Enhancements +============ + +- Added Cache config for reserved characters +- The ``addForeignKey`` function of the ``Forge`` class can now define composite foreign keys in an array +- The ``dropKey`` function of the ``Forge`` class can remove key + +Changes +======= + +- Always escape identifiers in the ``set``, ``setUpdateBatch``, and ``insertBatch`` functions in ``BaseBuilder``. + +Deprecations +============ + +- Deprecated ``CodeIgniter\\Cache\\Handlers\\BaseHandler::RESERVED_CHARACTERS`` in favor of the new config property diff --git a/user_guide_src/source/changelogs/v4.1.6.rst b/user_guide_src/source/changelogs/v4.1.6.rst new file mode 100644 index 000000000000..80800ecbc94b --- /dev/null +++ b/user_guide_src/source/changelogs/v4.1.6.rst @@ -0,0 +1,25 @@ +Version 4.1.6 +############# + +Release Date: Not released + +**4.1.6 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 1 + +BREAKING +======== + +Enhancements +============ + +Changes +======= + +Deprecations +============ + +Bugs Fixed +========== diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index f7d0ea380fb3..210226b51edc 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -205,6 +205,27 @@ Options: * ``--suffix``: Append the component suffix to the generated class name. * ``--force``: Set this flag to overwrite existing files on destination. +make:validation +--------------- + +Creates a new validation file. + +Usage: +====== +:: + + make:validation [options] + +Argument: +========= +* ``name``: The name of the validation class. **[REQUIRED]** + +Options: +======== +* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix``: Append the component suffix to the generated class name. +* ``--force``: Set this flag to overwrite existing files on destination. + .. note:: Do you need to have the generated code in a subfolder? Let's say if you want to create a controller class to reside in the ``Admin`` subfolder of the main ``Controllers`` folder, you will just need to prepend the subfolder to the class name, like this: ``php spark make:controller admin/login``. This diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index d7ddc0993015..6ba08ab80554 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -38,7 +38,7 @@ Getting Input from the User Sometimes you need to ask the user for more information. They might not have provided optional command-line arguments, or the script may have encountered an existing file and needs confirmation before overwriting. This is -handled with the ``prompt()`` method. +handled with the ``prompt()`` or ``promptByKey()`` method. You can provide a question by passing it in as the first parameter:: @@ -59,7 +59,39 @@ Finally, you can pass :ref:`validation ` rules to the answer input a Validation rules can also be written in the array syntax.:: - $email = CLI::prompt('What is your email?', null, ['required', 'valid_email']); + $email = CLI::prompt('What is your email?', null, ['required', 'valid_email']); + + +**promptByKey()** + +Predefined answers (options) for prompt sometimes need to be described or are too complex to select via their value. +``promptByKey()`` allows the user to select an option by its key instead of its value:: + + $fruit = CLI::promptByKey('These are your choices:', ['The red apple', 'The plump orange', 'The ripe banana']); + + //These are your choices: + // [0] The red apple + // [1] The plump orange + // [2] The ripe banana + // + //[0, 1, 2]: + +Named keys are also possible:: + + $fruit = CLI::promptByKey(['These are your choices:', 'Which would you like?'], [ + 'apple' => 'The red apple', + 'orange' => 'The plump orange', + 'banana' => 'The ripe banana' + ]); + + //These are your choices: + // [apple] The red apple + // [orange] The plump orange + // [banana] The ripe banana + // + //Which would you like? [apple, orange, banana]: + +Finally, you can pass :ref:`validation ` rules to the answer input as the third parameter, the acceptable answers are automatically restricted to the passed options. Providing Feedback ================== @@ -167,6 +199,15 @@ on the right with their descriptions. By default, this will wrap back to the lef doesn't allow things to line up in columns. In cases like this, you can pass in a number of spaces to pad every line after the first line, so that you will have a crisp column edge on the left:: + $titles = [ + 'task1a', + 'task1abc', + ]; + $descriptions = [ + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', + "Lorem Ipsum has been the industry's standard dummy text ever since the", + ]; + // Determine the maximum length of all titles // to determine the width of the left column $maxlen = max(array_map('strlen', $titles)); @@ -174,7 +215,11 @@ every line after the first line, so that you will have a crisp column edge on th for ($i = 0; $i < count($titles); $i++) { CLI::write( // Display the title on the left of the row - $titles[$i] . ' ' . + substr( + $titles[$i] . str_repeat(' ', $maxlen + 3), + 0, + $maxlen + 3 + ) . // Wrap the descriptions in a right-hand column // with its left side 3 characters wider than // the longest item on the left. @@ -186,11 +231,12 @@ Would create something like this: .. code-block:: none - task1a Lorem Ipsum is simply dummy - text of the printing and typesetting - industry. - task1abc Lorem Ipsum has been the industry's - standard dummy text ever since the + task1a Lorem Ipsum is simply dummy + text of the printing and + typesetting industry. + task1abc Lorem Ipsum has been the + industry's standard dummy + text ever since the **newLine()** diff --git a/user_guide_src/source/concepts/security.rst b/user_guide_src/source/concepts/security.rst index d13c47d73564..63581c9510dd 100644 --- a/user_guide_src/source/concepts/security.rst +++ b/user_guide_src/source/concepts/security.rst @@ -57,7 +57,7 @@ CodeIgniter provisions ---------------------- - `Session <../libraries/sessions.html>`_ library -- `HTTP library <../incoming/incomingrequest.html>`_ provides for CSRF validation +- :doc:`Security ` library provides for CSRF validation - Easy to add third party authentication ***************************** @@ -162,7 +162,7 @@ CodeIgniter provisions ---------------------- - Public folder, with application and system outside -- `HTTP library <../incoming/incomingrequest.html>`_ provides for CSRF validation +- :doc:`Security ` library provides for CSRF validation ************************************ A8 Cross Site Request Forgery (CSRF) @@ -181,7 +181,7 @@ OWASP recommendations CodeIgniter provisions ---------------------- -- `HTTP library <../incoming/incomingrequest.html>`_ provides for CSRF validation +- :doc:`Security ` library provides for CSRF validation ********************************************** A9 Using Components with Known Vulnerabilities diff --git a/user_guide_src/source/concepts/structure.rst b/user_guide_src/source/concepts/structure.rst index 40c5f868ee73..14bb3a59304b 100644 --- a/user_guide_src/source/concepts/structure.rst +++ b/user_guide_src/source/concepts/structure.rst @@ -84,6 +84,6 @@ Modifying Directory Locations ----------------------------- If you've relocated any of the main directories, you can change the configuration -settings inside ``app/Config/Paths``. +settings inside **app/Config/Paths.php**. Please read `Managing your Applications <../general/managing_apps.html>`_ diff --git a/user_guide_src/source/conf.py b/user_guide_src/source/conf.py index f341ddfc25c4..16195c089833 100644 --- a/user_guide_src/source/conf.py +++ b/user_guide_src/source/conf.py @@ -24,7 +24,7 @@ version = '4.1' # The full version, including alpha/beta/rc tags. -release = '4.1.4' +release = '4.1.5' # -- General configuration --------------------------------------------------- diff --git a/user_guide_src/source/database/call_function.rst b/user_guide_src/source/database/call_function.rst index 6c0453146cd6..9617c2361df0 100644 --- a/user_guide_src/source/database/call_function.rst +++ b/user_guide_src/source/database/call_function.rst @@ -40,4 +40,4 @@ The result ID can be accessed from within your result object, like this:: $query = $db->query("SOME QUERY"); - $query->resultID; \ No newline at end of file + $query->resultID; diff --git a/user_guide_src/source/database/configuration.rst b/user_guide_src/source/database/configuration.rst index 53b2c3f932b0..f1776d93151d 100644 --- a/user_guide_src/source/database/configuration.rst +++ b/user_guide_src/source/database/configuration.rst @@ -9,7 +9,7 @@ Database Configuration CodeIgniter has a config file that lets you store your database connection values (username, password, database name, etc.). The config file is located at **app/Config/Database.php**. You can also set -database connection values in the .env file. See below for more details. +database connection values in the **.env** file. See below for more details. The config settings are stored in a class property that is an array with this prototype:: @@ -162,7 +162,7 @@ within the class' constructor:: Configuring With .env File -------------------------- -You can also save your configuration values within a ``.env`` file with the current server's +You can also save your configuration values within a **.env** file with the current server's database settings. You only need to enter the values that change from what is in the default group's configuration settings. The values should be name following this format, where ``default`` is the group name:: diff --git a/user_guide_src/source/database/index.rst b/user_guide_src/source/database/index.rst index 62002afd00a8..73cd1a4ecff9 100644 --- a/user_guide_src/source/database/index.rst +++ b/user_guide_src/source/database/index.rst @@ -20,4 +20,4 @@ patterns. The database functions offer clear, simple syntax. Getting MetaData Custom Function Calls Database Events - Database Utilities \ No newline at end of file + Database Utilities diff --git a/user_guide_src/source/database/queries.rst b/user_guide_src/source/database/queries.rst index 366b264cc45c..af15bacea038 100644 --- a/user_guide_src/source/database/queries.rst +++ b/user_guide_src/source/database/queries.rst @@ -15,6 +15,7 @@ Regular Queries To submit a query, use the **query** function:: + $db = db_connect(); $db->query('YOUR QUERY HERE'); The ``query()`` function returns a database result **object** when "read" diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 78c1b3731fd5..cb0771356098 100755 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -244,7 +244,10 @@ This function enables you to set **WHERE** clauses using one of four methods: .. note:: All values passed to this function are escaped automatically, - producing safer queries. + producing safer queries, except when using a custom string. + +.. note:: ``$builder->where()`` accepts an optional third parameter. If you set it to + ``false``, CodeIgniter will not try to protect your field or table names. #. **Simple key/value method:** @@ -256,9 +259,7 @@ methods: Notice that the equal sign is added for you. If you use multiple function calls they will be chained together with - AND between them: - - :: + AND between them:: $builder->where('name', $name); $builder->where('title', $title); @@ -268,9 +269,7 @@ methods: #. **Custom key/value method:** You can include an operator in the first parameter in order to - control the comparison: - - :: + control the comparison:: $builder->where('name !=', $name); $builder->where('id <', $id); @@ -284,30 +283,30 @@ methods: $builder->where($array); // Produces: WHERE name = 'Joe' AND title = 'boss' AND status = 'active' - You can include your own operators using this method as well: - - :: + You can include your own operators using this method as well:: $array = ['name !=' => $name, 'id <' => $id, 'date >' => $date]; $builder->where($array); #. **Custom string:** + You can write your own clauses manually:: $where = "name='Joe' AND status='boss' OR status='active'"; $builder->where($where); - ``$builder->where()`` accepts an optional third parameter. If you set it to - ``false``, CodeIgniter will not try to protect your field or table names. + If you are using user-supplied data within the string, you MUST escape the + data manually. Failure to do so could result in SQL injections. :: - $builder->where('MATCH (field) AGAINST ("value")', null, false); + $name = $builder->db->escape('Joe'); + $where = "name={$name} AND status='boss' OR status='active'"; + $builder->where($where); #. **Subqueries:** - You can use an anonymous function to create a subquery. - :: + You can use an anonymous function to create a subquery:: $builder->where('advance_amount <', function (BaseBuilder $builder) { return $builder->select('MAX(advance_amount)', false)->from('orders')->where('id >', 2); @@ -317,48 +316,38 @@ methods: **$builder->orWhere()** This function is identical to the one above, except that multiple -instances are joined by OR - - :: +instances are joined by OR:: - $builder->where('name !=', $name); - $builder->orWhere('id >', $id); - // Produces: WHERE name != 'Joe' OR id > 50 + $builder->where('name !=', $name); + $builder->orWhere('id >', $id); + // Produces: WHERE name != 'Joe' OR id > 50 **$builder->whereIn()** Generates a WHERE field IN ('item', 'item') SQL query joined with AND if -appropriate +appropriate:: - :: + $names = ['Frank', 'Todd', 'James']; + $builder->whereIn('username', $names); + // Produces: WHERE username IN ('Frank', 'Todd', 'James') - $names = ['Frank', 'Todd', 'James']; - $builder->whereIn('username', $names); - // Produces: WHERE username IN ('Frank', 'Todd', 'James') +You can use subqueries instead of an array of values:: -You can use subqueries instead of an array of values. - - :: - - $builder->whereIn('id', function (BaseBuilder $builder) { - return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); - }); - // Produces: WHERE "id" IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) + $builder->whereIn('id', function (BaseBuilder $builder) { + return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); + }); + // Produces: WHERE "id" IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) **$builder->orWhereIn()** Generates a ``WHERE field IN ('item', 'item')`` SQL query joined with OR if -appropriate - - :: +appropriate:: $names = ['Frank', 'Todd', 'James']; $builder->orWhereIn('username', $names); // Produces: OR username IN ('Frank', 'Todd', 'James') -You can use subqueries instead of an array of values. - - :: +You can use subqueries instead of an array of values:: $builder->orWhereIn('id', function (BaseBuilder $builder) { return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); @@ -369,45 +358,36 @@ You can use subqueries instead of an array of values. **$builder->whereNotIn()** Generates a WHERE field NOT IN ('item', 'item') SQL query joined with -AND if appropriate +AND if appropriate:: - :: - - $names = ['Frank', 'Todd', 'James']; - $builder->whereNotIn('username', $names); - // Produces: WHERE username NOT IN ('Frank', 'Todd', 'James') + $names = ['Frank', 'Todd', 'James']; + $builder->whereNotIn('username', $names); + // Produces: WHERE username NOT IN ('Frank', 'Todd', 'James') -You can use subqueries instead of an array of values. +You can use subqueries instead of an array of values:: - :: - - $builder->whereNotIn('id', function (BaseBuilder $builder) { - return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); - }); - - // Produces: WHERE "id" NOT IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) + $builder->whereNotIn('id', function (BaseBuilder $builder) { + return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); + }); + // Produces: WHERE "id" NOT IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) **$builder->orWhereNotIn()** Generates a ``WHERE field NOT IN ('item', 'item')`` SQL query joined with OR -if appropriate +if appropriate:: - :: + $names = ['Frank', 'Todd', 'James']; + $builder->orWhereNotIn('username', $names); + // Produces: OR username NOT IN ('Frank', 'Todd', 'James') - $names = ['Frank', 'Todd', 'James']; - $builder->orWhereNotIn('username', $names); - // Produces: OR username NOT IN ('Frank', 'Todd', 'James') +You can use subqueries instead of an array of values:: -You can use subqueries instead of an array of values. - - :: - - $builder->orWhereNotIn('id', function (BaseBuilder $builder) { - return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); - }); + $builder->orWhereNotIn('id', function (BaseBuilder $builder) { + return $builder->select('job_id')->from('users_jobs')->where('user_id', 3); + }); - // Produces: OR "id" NOT IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) + // Produces: OR "id" NOT IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3) ************************ Looking for Similar Data @@ -647,7 +627,7 @@ searches. $builder->havingLike('title', 'match', 'before'); // Produces: HAVING `title` LIKE '%match' ESCAPE '!' $builder->havingLike('title', 'match', 'after'); // Produces: HAVING `title` LIKE 'match%' ESCAPE '!' - $builder->havingLike('title', 'match', 'both'); // Produces: HAVING `title` LIKE '%match%' ESCAPE '!' + $builder->havingLike('title', 'match', 'both'); // Produces: HAVING `title` LIKE '%match%' ESCAPE '!' #. **Associative array method:** @@ -1066,9 +1046,9 @@ is an example using an array:: $builder->update($data); // Produces: // - // UPDATE mytable - // SET title = '{$title}', name = '{$name}', date = '{$date}' - // WHERE id = $id + // UPDATE mytable + // SET title = '{$title}', name = '{$name}', date = '{$date}' + // WHERE id = $id Or you can supply an object:: @@ -1258,8 +1238,8 @@ Class Reference .. php:method:: db() - :returns: The database connection in use - :rtype: ``ConnectionInterface`` + :returns: The database connection in use + :rtype: ``ConnectionInterface`` Returns the current database connection from ``$db``. Useful for accessing ``ConnectionInterface`` methods that are not directly @@ -1267,17 +1247,17 @@ Class Reference .. php:method:: resetQuery() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Resets the current Query Builder state. Useful when you want - to build a query that can be canceled under certain conditions. + to build a query that can be cancelled under certain conditions. .. php:method:: countAllResults([$reset = true]) :param bool $reset: Whether to reset values for SELECTs - :returns: Number of rows in the query result - :rtype: int + :returns: Number of rows in the query result + :rtype: int Generates a platform-specific query string that counts all records returned by an Query Builder query. @@ -1285,8 +1265,8 @@ Class Reference .. php:method:: countAll([$reset = true]) :param bool $reset: Whether to reset values for SELECTs - :returns: Number of rows in the query result - :rtype: int + :returns: Number of rows in the query result + :rtype: int Generates a platform-specific query string that counts all records in the particular table. @@ -1297,7 +1277,7 @@ Class Reference :param int $offset: The OFFSET clause :param bool $reset: Do we want to clear query builder values? :returns: ``\CodeIgniter\Database\ResultInterface`` instance (method chaining) - :rtype: ``\CodeIgniter\Database\ResultInterface`` + :rtype: ``\CodeIgniter\Database\ResultInterface`` Compiles and runs ``SELECT`` statement based on the already called Query Builder methods. @@ -1308,8 +1288,8 @@ Class Reference :param int $limit: The LIMIT clause :param int $offset: The OFFSET clause :param bool $reset: Do we want to clear query builder values? - :returns: ``\CodeIgniter\Database\ResultInterface`` instance (method chaining) - :rtype: ``\CodeIgniter\Database\ResultInterface`` + :returns: ``\CodeIgniter\Database\ResultInterface`` instance (method chaining) + :rtype: ``\CodeIgniter\Database\ResultInterface`` Same as ``get()``, but also allows the WHERE to be added directly. @@ -1317,8 +1297,8 @@ Class Reference :param string $select: The SELECT portion of a query :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT`` clause to a query. @@ -1326,8 +1306,8 @@ Class Reference :param string $select: Field to compute the average of :param string $alias: Alias for the resulting value name - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT AVG(field)`` clause to a query. @@ -1335,8 +1315,8 @@ Class Reference :param string $select: Field to compute the maximum of :param string $alias: Alias for the resulting value name - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT MAX(field)`` clause to a query. @@ -1344,8 +1324,8 @@ Class Reference :param string $select: Field to compute the minimum of :param string $alias: Alias for the resulting value name - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT MIN(field)`` clause to a query. @@ -1353,8 +1333,8 @@ Class Reference :param string $select: Field to compute the sum of :param string $alias: Alias for the resulting value name - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT SUM(field)`` clause to a query. @@ -1362,16 +1342,16 @@ Class Reference :param string $select: Field to compute the average of :param string $alias: Alias for the resulting value name - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``SELECT COUNT(field)`` clause to a query. .. php:method:: distinct([$val = true]) :param bool $val: Desired value of the "distinct" flag - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Sets a flag which tells the query builder to add a ``DISTINCT`` clause to the ``SELECT`` portion of the query. @@ -1379,9 +1359,9 @@ Class Reference .. php:method:: from($from[, $overwrite = false]) :param mixed $from: Table name(s); string or array - :param bool $overwrite: Should we remove the first table existing? - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $overwrite: Should we remove the first table existing? + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Specifies the ``FROM`` clause of a query. @@ -1390,9 +1370,9 @@ Class Reference :param string $table: Table name to join :param string $cond: The JOIN ON condition :param string $type: The JOIN type - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``JOIN`` clause to a query. @@ -1400,9 +1380,9 @@ Class Reference :param mixed $key: Name of field to compare, or associative array :param mixed $value: If a single key, compared to this value - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates the ``WHERE`` portion of the query. Separates multiple calls with ``AND``. @@ -1411,8 +1391,8 @@ Class Reference :param mixed $key: Name of field to compare, or associative array :param mixed $value: If a single key, compared to this value :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates the ``WHERE`` portion of the query. Separates multiple calls with ``OR``. @@ -1421,8 +1401,8 @@ Class Reference :param string $key: The field to search :param array|Closure $values: Array of target values, or anonymous function for subquery :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``WHERE`` field ``IN('item', 'item')`` SQL query, joined with ``OR`` if appropriate. @@ -1431,8 +1411,8 @@ Class Reference :param string $key: The field to search :param array|Closure $values: Array of target values, or anonymous function for subquery :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``WHERE`` field ``NOT IN('item', 'item')`` SQL query, joined with ``OR`` if appropriate. @@ -1441,8 +1421,8 @@ Class Reference :param string $key: Name of field to examine :param array|Closure $values: Array of target values, or anonymous function for subquery :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``WHERE`` field ``IN('item', 'item')`` SQL query, joined with ``AND`` if appropriate. @@ -1450,44 +1430,44 @@ Class Reference :param string $key: Name of field to examine :param array|Closure $values: Array of target values, or anonymous function for subquery - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``WHERE`` field ``NOT IN('item', 'item')`` SQL query, joined with ``AND`` if appropriate. .. php:method:: groupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression, using ``AND`` for the conditions inside it. .. php:method:: orGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression, using ``OR`` for the conditions inside it. .. php:method:: notGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression, using ``AND NOT`` for the conditions inside it. .. php:method:: orNotGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression, using ``OR NOT`` for the conditions inside it. .. php:method:: groupEnd() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Ends a group expression. @@ -1496,10 +1476,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``LIKE`` clause to a query, separating multiple calls with ``AND``. @@ -1508,10 +1488,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``LIKE`` clause to a query, separating multiple class with ``OR``. @@ -1520,10 +1500,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``NOT LIKE`` clause to a query, separating multiple calls with ``AND``. @@ -1532,10 +1512,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``NOT LIKE`` clause to a query, separating multiple calls with ``OR``. @@ -1544,8 +1524,8 @@ Class Reference :param mixed $key: Identifier (string) or associative array of field/value pairs :param string $value: Value sought if $key is an identifier :param string $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``HAVING`` clause to a query, separating multiple calls with ``AND``. @@ -1554,8 +1534,8 @@ Class Reference :param mixed $key: Identifier (string) or associative array of field/value pairs :param string $value: Value sought if $key is an identifier :param string $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``HAVING`` clause to a query, separating multiple calls with ``OR``. @@ -1563,9 +1543,9 @@ Class Reference :param string $key: The field to search :param array|Closure $values: Array of target values, or anonymous function for subquery - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``HAVING`` field IN('item', 'item') SQL query, joined with ``OR`` if appropriate. @@ -1573,9 +1553,9 @@ Class Reference :param string $key: The field to search :param array|Closure $values: Array of target values, or anonymous function for subquery - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``HAVING`` field ``NOT IN('item', 'item')`` SQL query, joined with ``OR`` if appropriate. @@ -1584,8 +1564,8 @@ Class Reference :param string $key: Name of field to examine :param array|Closure $values: Array of target values, or anonymous function for subquery :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``HAVING`` field ``IN('item', 'item')`` SQL query, joined with ``AND`` if appropriate. @@ -1595,8 +1575,8 @@ Class Reference :param array|Closure $values: Array of target values, or anonymous function for subquery :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Generates a ``HAVING`` field ``NOT IN('item', 'item')`` SQL query, joined with ``AND`` if appropriate. @@ -1605,10 +1585,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``LIKE`` clause to a ``HAVING`` part of the query, separating multiple calls with ``AND``. @@ -1617,10 +1597,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :rtype: ``BaseBuilder`` Adds a ``LIKE`` clause to a ``HAVING`` part of the query, separating multiple class with ``OR``. @@ -1629,10 +1609,10 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values and identifiers :param bool $insensitiveSearch: Whether to force a case-insensitive search - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``NOT LIKE`` clause to a ``HAVING`` part of the query, separating multiple calls with ``AND``. @@ -1641,52 +1621,52 @@ Class Reference :param string $field: Field name :param string $match: Text portion to match :param string $side: Which side of the expression to put the '%' wildcard on - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``NOT LIKE`` clause to a ``HAVING`` part of the query, separating multiple calls with ``OR``. .. php:method:: havingGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression for ``HAVING`` clause, using ``AND`` for the conditions inside it. .. php:method:: orHavingGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression for ``HAVING`` clause, using ``OR`` for the conditions inside it. .. php:method:: notHavingGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression for ``HAVING`` clause, using ``AND NOT`` for the conditions inside it. .. php:method:: orNotHavingGroupStart() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Starts a group expression for ``HAVING`` clause, using ``OR NOT`` for the conditions inside it. .. php:method:: havingGroupEnd() - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Ends a group expression for ``HAVING`` clause. .. php:method:: groupBy($by[, $escape = null]) :param mixed $by: Field(s) to group by; string or array - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds a ``GROUP BY`` clause to a query. @@ -1694,9 +1674,9 @@ Class Reference :param string $orderby: Field to order by :param string $direction: The order requested - ASC, DESC or random - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values and identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds an ``ORDER BY`` clause to a query. @@ -1704,45 +1684,45 @@ Class Reference :param int $value: Number of rows to limit the results to :param int $offset: Number of rows to skip - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds ``LIMIT`` and ``OFFSET`` clauses to a query. .. php:method:: offset($offset) :param int $offset: Number of rows to skip - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds an ``OFFSET`` clause to a query. .. php:method:: set($key[, $value = ''[, $escape = null]]) :param mixed $key: Field name, or an array of field/value pairs - :param string $value: Field value, if $key is a single field - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param mixed $value: Field value, if $key is a single field + :param bool $escape: Whether to escape values + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds field/value pairs to be passed later to ``insert()``, ``update()`` or ``replace()``. .. php:method:: insert([$set = null[, $escape = null]]) :param array $set: An associative array of field/value pairs - :param bool $escape: Whether to escape values and identifiers - :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :param bool $escape: Whether to escape values + :returns: ``true`` on success, ``false`` on failure + :rtype: bool Compiles and executes an ``INSERT`` statement. .. php:method:: insertBatch([$set = null[, $escape = null[, $batch_size = 100]]]) :param array $set: Data to insert - :param bool $escape: Whether to escape values and identifiers + :param bool $escape: Whether to escape values :param int $batch_size: Count of rows to insert at once :returns: Number of rows inserted or ``false`` on failure - :rtype: int|false + :rtype: int|false Compiles and executes batch ``INSERT`` statements. @@ -1754,9 +1734,9 @@ Class Reference :param mixed $key: Field name or an array of field/value pairs :param string $value: Field value, if $key is a single field - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds field/value pairs to be inserted in a table later via ``insertBatch()``. @@ -1765,8 +1745,8 @@ Class Reference :param array $set: An associative array of field/value pairs :param string $where: The WHERE clause :param int $limit: The LIMIT clause - :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :returns: ``true`` on success, ``false`` on failure + :rtype: bool Compiles and executes an ``UPDATE`` statement. @@ -1775,8 +1755,8 @@ Class Reference :param array $set: Field name, or an associative array of field/value pairs :param string $value: Field value, if $set is a single field :param int $batch_size: Count of conditions to group in a single query - :returns: Number of rows updated or ``false`` on failure - :rtype: int|false + :returns: Number of rows updated or ``false`` on failure + :rtype: int|false Compiles and executes batch ``UPDATE`` statements. @@ -1788,9 +1768,9 @@ Class Reference :param mixed $key: Field name or an array of field/value pairs :param string $value: Field value, if $key is a single field - :param bool $escape: Whether to escape values and identifiers - :returns: ``BaseBuilder`` instance (method chaining) - :rtype: ``BaseBuilder`` + :param bool $escape: Whether to escape values + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` Adds field/value pairs to be updated in a table later via ``updateBatch()``. @@ -1798,7 +1778,7 @@ Class Reference :param array $set: An associative array of field/value pairs :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :rtype: bool Compiles and executes a ``REPLACE`` statement. @@ -1807,8 +1787,8 @@ Class Reference :param string $where: The WHERE clause :param int $limit: The LIMIT clause :param bool $reset_data: true to reset the query "write" clause - :returns: ``BaseBuilder`` instance (method chaining) or ``false`` on failure - :rtype: ``BaseBuilder|false`` + :returns: ``BaseBuilder`` instance (method chaining) or ``false`` on failure + :rtype: ``BaseBuilder|false`` Compiles and executes a ``DELETE`` query. @@ -1832,8 +1812,8 @@ Class Reference .. php:method:: truncate() - :returns: ``true`` on success, ``false`` on failure, string on test mode - :rtype: bool|string + :returns: ``true`` on success, ``false`` on failure, string on test mode + :rtype: bool|string Executes a ``TRUNCATE`` statement on a table. @@ -1843,7 +1823,7 @@ Class Reference .. php:method:: emptyTable() :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :rtype: bool Deletes all records from a table via a ``DELETE`` statement. @@ -1851,7 +1831,7 @@ Class Reference :param bool $reset: Whether to reset the current QB values or not :returns: The compiled SQL statement as a string - :rtype: string + :rtype: string Compiles a ``SELECT`` statement and returns it as a string. @@ -1859,7 +1839,7 @@ Class Reference :param bool $reset: Whether to reset the current QB values or not :returns: The compiled SQL statement as a string - :rtype: string + :rtype: string Compiles an ``INSERT`` statement and returns it as a string. @@ -1867,7 +1847,7 @@ Class Reference :param bool $reset: Whether to reset the current QB values or not :returns: The compiled SQL statement as a string - :rtype: string + :rtype: string Compiles an ``UPDATE`` statement and returns it as a string. @@ -1875,6 +1855,6 @@ Class Reference :param bool $reset: Whether to reset the current QB values or not :returns: The compiled SQL statement as a string - :rtype: string + :rtype: string Compiles a ``DELETE`` statement and returns it as a string. diff --git a/user_guide_src/source/database/results.rst b/user_guide_src/source/database/results.rst index e3e573d5c460..809de018d3c3 100644 --- a/user_guide_src/source/database/results.rst +++ b/user_guide_src/source/database/results.rst @@ -159,6 +159,36 @@ it returns the current row and moves the internal data pointer ahead. echo $row->body; } +For use with MySQLi you may set MySQLi's result mode to +``MYSQLI_USE_RESULT`` for maximum memory savings. Use of this is not +generally recommended but it can be beneficial in some circumstances +such as writing large queries to csv. If you change the result mode +be aware of the tradeoffs associated with it. + +:: + + $db->resultMode = MYSQLI_USE_RESULT; // for unbuffered results + + $query = $db->query("YOUR QUERY"); + + $file = new \CodeIgniter\Files\File(WRITEPATH.'data.csv'); + + $csv = $file->openFile('w'); + + while ($row = $query->getUnbufferedRow('array')) + { + $csv->fputcsv($row); + } + + $db->resultMode = MYSQLI_STORE_RESULT; // return to default mode + +.. note:: When using ``MYSQLI_USE_RESULT`` all subsequent calls on the same + connection will result in error until all records have been fetched or + a ``freeResult()`` call has been made. The ``getNumRows()`` method will only + return the number of rows based on the current position of the data pointer. + MyISAM tables will remain locked until all the records have been fetched + or a ``freeResult()`` call has been made. + You can optionally pass 'object' (default) or 'array' in order to specify the returned value's type:: @@ -334,9 +364,9 @@ Class Reference .. php:method:: getResult([$type = 'object']) - :param string $type: Type of requested results - array, object, or class name - :returns: Array containing the fetched rows - :rtype: array + :param string $type: Type of requested results - array, object, or class name + :returns: Array containing the fetched rows + :rtype: array A wrapper for the ``getResultArray()``, ``getResultObject()`` and ``getCustomResultObject()`` methods. @@ -345,8 +375,8 @@ Class Reference .. php:method:: getResultArray() - :returns: Array containing the fetched rows - :rtype: array + :returns: Array containing the fetched rows + :rtype: array Returns the query results as an array of rows, where each row is itself an associative array. @@ -355,8 +385,8 @@ Class Reference .. php:method:: getResultObject() - :returns: Array containing the fetched rows - :rtype: array + :returns: Array containing the fetched rows + :rtype: array Returns the query results as an array of rows, where each row is an object of type ``stdClass``. @@ -365,19 +395,19 @@ Class Reference .. php:method:: getCustomResultObject($class_name) - :param string $class_name: Class name for the resulting rows - :returns: Array containing the fetched rows - :rtype: array + :param string $class_name: Class name for the resulting rows + :returns: Array containing the fetched rows + :rtype: array Returns the query results as an array of rows, where each row is an instance of the specified class. .. php:method:: getRow([$n = 0[, $type = 'object']]) - :param int $n: Index of the query results row to be returned - :param string $type: Type of the requested result - array, object, or class name - :returns: The requested row or null if it doesn't exist - :rtype: mixed + :param int $n: Index of the query results row to be returned + :param string $type: Type of the requested result - array, object, or class name + :returns: The requested row or null if it doesn't exist + :rtype: mixed A wrapper for the ``getRowArray()``, ``getRowObject()`` and ``getCustomRowObject()`` methods. @@ -386,9 +416,9 @@ Class Reference .. php:method:: getUnbufferedRow([$type = 'object']) - :param string $type: Type of the requested result - array, object, or class name - :returns: Next row from the result set or null if it doesn't exist - :rtype: mixed + :param string $type: Type of the requested result - array, object, or class name + :returns: Next row from the result set or null if it doesn't exist + :rtype: mixed Fetches the next result row and returns it in the requested form. @@ -397,9 +427,9 @@ Class Reference .. php:method:: getRowArray([$n = 0]) - :param int $n: Index of the query results row to be returned - :returns: The requested row or null if it doesn't exist - :rtype: array + :param int $n: Index of the query results row to be returned + :returns: The requested row or null if it doesn't exist + :rtype: array Returns the requested result row as an associative array. @@ -407,9 +437,9 @@ Class Reference .. php:method:: getRowObject([$n = 0]) - :param int $n: Index of the query results row to be returned - :returns: The requested row or null if it doesn't exist - :rtype: stdClass + :param int $n: Index of the query results row to be returned + :returns: The requested row or null if it doesn't exist + :rtype: stdClass Returns the requested result row as an object of type ``stdClass``. @@ -418,19 +448,19 @@ Class Reference .. php:method:: getCustomRowObject($n, $type) - :param int $n: Index of the results row to return - :param string $class_name: Class name for the resulting row - :returns: The requested row or null if it doesn't exist - :rtype: $type + :param int $n: Index of the results row to return + :param string $class_name: Class name for the resulting row + :returns: The requested row or null if it doesn't exist + :rtype: $type Returns the requested result row as an instance of the requested class. .. php:method:: dataSeek([$n = 0]) - :param int $n: Index of the results row to be returned next - :returns: true on success, false on failure - :rtype: bool + :param int $n: Index of the results row to be returned next + :returns: true on success, false on failure + :rtype: bool Moves the internal results row pointer to the desired offset. @@ -438,48 +468,48 @@ Class Reference .. php:method:: setRow($key[, $value = null]) - :param mixed $key: Column name or array of key/value pairs - :param mixed $value: Value to assign to the column, $key is a single field name - :rtype: void + :param mixed $key: Column name or array of key/value pairs + :param mixed $value: Value to assign to the column, $key is a single field name + :rtype: void Assigns a value to a particular column. .. php:method:: getNextRow([$type = 'object']) - :param string $type: Type of the requested result - array, object, or class name - :returns: Next row of result set, or null if it doesn't exist - :rtype: mixed + :param string $type: Type of the requested result - array, object, or class name + :returns: Next row of result set, or null if it doesn't exist + :rtype: mixed Returns the next row from the result set. .. php:method:: getPreviousRow([$type = 'object']) - :param string $type: Type of the requested result - array, object, or class name - :returns: Previous row of result set, or null if it doesn't exist - :rtype: mixed + :param string $type: Type of the requested result - array, object, or class name + :returns: Previous row of result set, or null if it doesn't exist + :rtype: mixed Returns the previous row from the result set. .. php:method:: getFirstRow([$type = 'object']) - :param string $type: Type of the requested result - array, object, or class name - :returns: First row of result set, or null if it doesn't exist - :rtype: mixed + :param string $type: Type of the requested result - array, object, or class name + :returns: First row of result set, or null if it doesn't exist + :rtype: mixed Returns the first row from the result set. .. php:method:: getLastRow([$type = 'object']) - :param string $type: Type of the requested result - array, object, or class name - :returns: Last row of result set, or null if it doesn't exist - :rtype: mixed + :param string $type: Type of the requested result - array, object, or class name + :returns: Last row of result set, or null if it doesn't exist + :rtype: mixed Returns the last row from the result set. .. php:method:: getFieldCount() - :returns: Number of fields in the result set - :rtype: int + :returns: Number of fields in the result set + :rtype: int Returns the number of fields in the result set. @@ -487,30 +517,30 @@ Class Reference .. php:method:: getFieldNames() - :returns: Array of column names - :rtype: array + :returns: Array of column names + :rtype: array Returns an array containing the field names in the result set. .. php:method:: getFieldData() - :returns: Array containing field meta-data - :rtype: array + :returns: Array containing field meta-data + :rtype: array Generates an array of ``stdClass`` objects containing field meta-data. .. php:method:: getNumRows() - :returns: Number of rows in result set - :rtype: int + :returns: Number of rows in result set + :rtype: int Returns number of rows returned by the query .. php:method:: freeResult() - :rtype: void + :rtype: void Frees a result set. diff --git a/user_guide_src/source/dbmgmt/forge.rst b/user_guide_src/source/dbmgmt/forge.rst index aba92fc634b2..e1ed99e02c5c 100644 --- a/user_guide_src/source/dbmgmt/forge.rst +++ b/user_guide_src/source/dbmgmt/forge.rst @@ -13,16 +13,16 @@ Initializing the Forge Class **************************** .. important:: In order to initialize the Forge class, your database - driver must already be running, since the forge class relies on it. + driver must already be running, since the forge class relies on it. Load the Forge Class as follows:: - $forge = \Config\Database::forge(); + $forge = \Config\Database::forge(); You can also pass another database group name to the DB Forge loader, in case the database you want to manage isn't the default one:: - $this->myforge = \Config\Database::forge('other_db'); + $this->myforge = \Config\Database::forge('other_db'); In the above example, we're passing the name of a different database group to connect to as the first parameter. @@ -36,27 +36,27 @@ Creating and Dropping Databases Permits you to create the database specified in the first parameter. Returns true/false based on success or failure:: - if ($forge->createDatabase('my_db')) { - echo 'Database created!'; - } + if ($forge->createDatabase('my_db')) { + echo 'Database created!'; + } An optional second parameter set to true will add IF EXISTS statement or will check if a database exists before create it (depending on DBMS). :: - $forge->createDatabase('my_db', true); - // gives CREATE DATABASE IF NOT EXISTS my_db - // or will check if a database exists + $forge->createDatabase('my_db', true); + // gives CREATE DATABASE IF NOT EXISTS `my_db` + // or will check if a database exists **$forge->dropDatabase('db_name')** Permits you to drop the database specified in the first parameter. Returns true/false based on success or failure:: - if ($forge->dropDatabase('my_db')) { - echo 'Database deleted!'; - } + if ($forge->dropDatabase('my_db')) { + echo 'Database deleted!'; + } Creating Databases in the Command Line ====================================== @@ -67,7 +67,7 @@ will complain that the database creation has failed. To start, just type the command and the name of the database (e.g., ``foo``):: - php spark db:create foo + php spark db:create foo If everything went fine, you should expect the ``Database "foo" successfully created.`` message displayed. @@ -76,12 +76,12 @@ for the file where the database will be created using the ``--ext`` option. Vali ``sqlite`` and defaults to ``db``. Remember that these should not be preceded by a period. :: - php spark db:create foo --ext sqlite - // will create the db file in WRITEPATH/foo.sqlite + php spark db:create foo --ext sqlite + // will create the db file in WRITEPATH/foo.sqlite .. note:: When using the special SQLite3 database name ``:memory:``, expect that the command will still - produce a success message but no database file is created. This is because SQLite3 will just use - an in-memory database. + produce a success message but no database file is created. This is because SQLite3 will just use + an in-memory database. **************************** Creating and Dropping Tables @@ -101,13 +101,13 @@ also require a 'constraint' key. :: - $fields = [ - 'users' => [ - 'type' => 'VARCHAR', - 'constraint' => 100, - ], - ]; - // will translate to "users VARCHAR(100)" when the field is added. + $fields = [ + 'users' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + ]; + // will translate to "users VARCHAR(100)" when the field is added. Additionally, the following key/values can be used: @@ -122,33 +122,33 @@ Additionally, the following key/values can be used: :: - $fields = [ - 'id' => [ - 'type' => 'INT', - 'constraint' => 5, - 'unsigned' => true, - 'auto_increment' => true - ], - 'title' => [ - 'type' => 'VARCHAR', - 'constraint' => '100', - 'unique' => true, - ], - 'author' => [ - 'type' =>'VARCHAR', - 'constraint' => 100, - 'default' => 'King of Town', - ], - 'description' => [ - 'type' => 'TEXT', - 'null' => true, - ], - 'status' => [ - 'type' => 'ENUM', - 'constraint' => ['publish', 'pending', 'draft'], - 'default' => 'pending', - ], - ]; + $fields = [ + 'id' => [ + 'type' => 'INT', + 'constraint' => 5, + 'unsigned' => true, + 'auto_increment' => true + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => '100', + 'unique' => true, + ], + 'author' => [ + 'type' =>'VARCHAR', + 'constraint' => 100, + 'default' => 'King of Town', + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['publish', 'pending', 'draft'], + 'default' => 'pending', + ], + ]; After the fields have been defined, they can be added using ``$forge->addField($fields);`` followed by a call to the @@ -166,7 +166,7 @@ string into the field definitions with addField() :: - $forge->addField("label varchar(100) NOT NULL DEFAULT 'default label'"); + $forge->addField("label varchar(100) NOT NULL DEFAULT 'default label'"); .. note:: Passing raw strings as fields cannot be followed by ``addKey()`` calls on those fields. @@ -181,8 +181,8 @@ Primary Key. :: - $forge->addField('id'); - // gives id INT(9) NOT NULL AUTO_INCREMENT + $forge->addField('id'); + // gives `id` INT(9) NOT NULL AUTO_INCREMENT Adding Keys =========== @@ -198,30 +198,30 @@ below is for MySQL. :: - $forge->addKey('blog_id', true); - // gives PRIMARY KEY `blog_id` (`blog_id`) + $forge->addKey('blog_id', true); + // gives PRIMARY KEY `blog_id` (`blog_id`) - $forge->addKey('blog_id', true); - $forge->addKey('site_id', true); - // gives PRIMARY KEY `blog_id_site_id` (`blog_id`, `site_id`) + $forge->addKey('blog_id', true); + $forge->addKey('site_id', true); + // gives PRIMARY KEY `blog_id_site_id` (`blog_id`, `site_id`) - $forge->addKey('blog_name'); - // gives KEY `blog_name` (`blog_name`) + $forge->addKey('blog_name'); + // gives KEY `blog_name` (`blog_name`) - $forge->addKey(['blog_name', 'blog_label']); - // gives KEY `blog_name_blog_label` (`blog_name`, `blog_label`) + $forge->addKey(['blog_name', 'blog_label']); + // gives KEY `blog_name_blog_label` (`blog_name`, `blog_label`) - $forge->addKey(['blog_id', 'uri'], false, true); - // gives UNIQUE KEY `blog_id_uri` (`blog_id`, `uri`) + $forge->addKey(['blog_id', 'uri'], false, true); + // gives UNIQUE KEY `blog_id_uri` (`blog_id`, `uri`) To make code reading more objective it is also possible to add primary and unique keys with specific methods:: - $forge->addPrimaryKey('blog_id'); - // gives PRIMARY KEY `blog_id` (`blog_id`) + $forge->addPrimaryKey('blog_id'); + // gives PRIMARY KEY `blog_id` (`blog_id`) - $forge->addUniqueKey(['blog_id', 'uri']); - // gives UNIQUE KEY `blog_id_uri` (`blog_id`, `uri`) + $forge->addUniqueKey(['blog_id', 'uri']); + // gives UNIQUE KEY `blog_id_uri` (`blog_id`, `uri`) Adding Foreign Keys @@ -230,13 +230,19 @@ Adding Foreign Keys Foreign Keys help to enforce relationships and actions across your tables. For tables that support Foreign Keys, you may add them directly in forge:: - $forge->addForeignKey('users_id','users','id'); - // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`) REFERENCES `users`(`id`) + $forge->addForeignKey('users_id','users','id'); + // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`) REFERENCES `users`(`id`) + + $forge->addForeignKey(['users_id', 'users_name'],'users',['id', 'name']); + // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`, `users_name`) REFERENCES `users`(`id`, `name`) You can specify the desired action for the "on delete" and "on update" properties of the constraint:: - $forge->addForeignKey('users_id','users','id','CASCADE','CASCADE'); - // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE + $forge->addForeignKey('users_id','users','id','CASCADE','CASCADE'); + // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE + + $forge->addForeignKey(['users_id', 'users_name'],'users',['id', 'name'],'CASCADE','CASCADE'); + // gives CONSTRAINT `TABLENAME_users_foreign` FOREIGN KEY(`users_id`, `users_name`) REFERENCES `users`(`id`, `name`) ON DELETE CASCADE ON UPDATE CASCADE Creating a table ================ @@ -246,26 +252,26 @@ with :: - $forge->createTable('table_name'); - // gives CREATE TABLE table_name + $forge->createTable('table_name'); + // gives CREATE TABLE table_name An optional second parameter set to true adds an "IF NOT EXISTS" clause into the definition :: - $forge->createTable('table_name', true); - // gives CREATE TABLE IF NOT EXISTS table_name + $forge->createTable('table_name', true); + // gives CREATE TABLE IF NOT EXISTS table_name You could also pass optional table attributes, such as MySQL's ``ENGINE``:: - $attributes = ['ENGINE' => 'InnoDB']; - $forge->createTable('table_name', false, $attributes); - // produces: CREATE TABLE `table_name` (...) ENGINE = InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci + $attributes = ['ENGINE' => 'InnoDB']; + $forge->createTable('table_name', false, $attributes); + // produces: CREATE TABLE `table_name` (...) ENGINE = InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci .. note:: Unless you specify the ``CHARACTER SET`` and/or ``COLLATE`` attributes, - ``createTable()`` will always add them with your configured *charset* - and *DBCollat* values, as long as they are not empty (MySQL only). + ``createTable()`` will always add them with your configured *charset* + and *DBCollat* values, as long as they are not empty (MySQL only). Dropping a table ================ @@ -274,19 +280,19 @@ Execute a DROP TABLE statement and optionally add an IF EXISTS clause. :: - // Produces: DROP TABLE table_name - $forge->dropTable('table_name'); + // Produces: DROP TABLE `table_name` + $forge->dropTable('table_name'); - // Produces: DROP TABLE IF EXISTS table_name - $forge->dropTable('table_name', true); + // Produces: DROP TABLE IF EXISTS `table_name` + $forge->dropTable('table_name', true); A third parameter can be passed to add a "CASCADE" option, which might be required for some drivers to handle removal of tables with foreign keys. :: - // Produces: DROP TABLE table_name CASCADE - $forge->dropTable('table_name', false, true); + // Produces: DROP TABLE `table_name` CASCADE + $forge->dropTable('table_name', false, true); Dropping a Foreign Key ====================== @@ -295,8 +301,19 @@ Execute a DROP FOREIGN KEY. :: - // Produces: ALTER TABLE 'tablename' DROP FOREIGN KEY 'users_foreign' - $forge->dropForeignKey('tablename','users_foreign'); + // Produces: ALTER TABLE `tablename` DROP FOREIGN KEY `users_foreign` + $forge->dropForeignKey('tablename','users_foreign'); + + +Dropping a Key +====================== + +Execute a DROP KEY. + +:: + + // Produces: DROP INDEX `users_index` ON `tablename` + $forge->dropKey('tablename','users_index'); Renaming a table ================ @@ -305,8 +322,8 @@ Executes a TABLE rename :: - $forge->renameTable('old_table_name', 'new_table_name'); - // gives ALTER TABLE old_table_name RENAME TO new_table_name + $forge->renameTable('old_table_name', 'new_table_name'); + // gives ALTER TABLE `old_table_name` RENAME TO `new_table_name` **************** Modifying Tables @@ -323,26 +340,26 @@ number of additional fields. :: - $fields = [ - 'preferences' => ['type' => 'TEXT'] - ]; - $forge->addColumn('table_name', $fields); - // Executes: ALTER TABLE table_name ADD preferences TEXT + $fields = [ + 'preferences' => ['type' => 'TEXT'] + ]; + $forge->addColumn('table_name', $fields); + // Executes: ALTER TABLE `table_name` ADD `preferences` TEXT If you are using MySQL or CUBIRD, then you can take advantage of their AFTER and FIRST clauses to position the new column. Examples:: - // Will place the new column after the `another_field` column: - $fields = [ - 'preferences' => ['type' => 'TEXT', 'after' => 'another_field'] - ]; + // Will place the new column after the `another_field` column: + $fields = [ + 'preferences' => ['type' => 'TEXT', 'after' => 'another_field'] + ]; - // Will place the new column at the start of the table definition: - $fields = [ - 'preferences' => ['type' => 'TEXT', 'first' => true] - ]; + // Will place the new column at the start of the table definition: + $fields = [ + 'preferences' => ['type' => 'TEXT', 'first' => true] + ]; Dropping Columns From a Table ============================== @@ -353,7 +370,7 @@ Used to remove a column from a table. :: - $forge->dropColumn('table_name', 'column_to_drop'); // to drop one single column + $forge->dropColumn('table_name', 'column_to_drop'); // to drop one single column Used to remove multiple columns from a table. @@ -373,14 +390,14 @@ change the name, you can add a "name" key into the field defining array. :: - $fields = [ - 'old_name' => [ - 'name' => 'new_name', - 'type' => 'TEXT', - ], - ]; - $forge->modifyColumn('table_name', $fields); - // gives ALTER TABLE table_name CHANGE old_name new_name TEXT + $fields = [ + 'old_name' => [ + 'name' => 'new_name', + 'type' => 'TEXT', + ], + ]; + $forge->modifyColumn('table_name', $fields); + // gives ALTER TABLE `table_name` CHANGE `old_name` `new_name` TEXT *************** Class Reference @@ -388,108 +405,120 @@ Class Reference .. php:class:: CodeIgniter\\Database\\Forge - .. php:method:: addColumn($table[, $field = []]) + .. php:method:: addColumn($table[, $field = []]) - :param string $table: Table name to add the column to - :param array $field: Column definition(s) - :returns: true on success, false on failure - :rtype: bool + :param string $table: Table name to add the column to + :param array $field: Column definition(s) + :returns: true on success, false on failure + :rtype: bool - Adds a column to a table. Usage: See `Adding a Column to a Table`_. + Adds a column to a table. Usage: See `Adding a Column to a Table`_. - .. php:method:: addField($field) + .. php:method:: addField($field) - :param array $field: Field definition to add - :returns: \CodeIgniter\Database\Forge instance (method chaining) - :rtype: \CodeIgniter\Database\Forge + :param array $field: Field definition to add + :returns: \CodeIgniter\Database\Forge instance (method chaining) + :rtype: \CodeIgniter\Database\Forge Adds a field to the set that will be used to create a table. Usage: See `Adding fields`_. - .. php:method:: addKey($key[, $primary = false[, $unique = false]]) + .. php:method:: addForeignKey($fieldName, $tableName, $tableField[, $onUpdate = '', $onDelete = '']) + + :param string|string[] $fieldName: Name of a key field or an array of fields + :param string $tableName: Name of a parent table + :param string|string[] $tableField: Name of a parent table field or an array of fields + :param string $onUpdate: Desired action for the “on update” + :param string $onDelete: Desired action for the “on delete” + :returns: \CodeIgniter\Database\Forge instance (method chaining) + :rtype: \CodeIgniter\Database\Forge + + Adds a foreign key to the set that will be used to create a table. Usage: See `Adding Foreign Keys`_. + + .. php:method:: addKey($key[, $primary = false[, $unique = false]]) - :param mixed $key: Name of a key field or an array of fields - :param bool $primary: Set to true if it should be a primary key or a regular one - :param bool $unique: Set to true if it should be a unique key or a regular one - :returns: \CodeIgniter\Database\Forge instance (method chaining) - :rtype: \CodeIgniter\Database\Forge + :param mixed $key: Name of a key field or an array of fields + :param bool $primary: Set to true if it should be a primary key or a regular one + :param bool $unique: Set to true if it should be a unique key or a regular one + :returns: \CodeIgniter\Database\Forge instance (method chaining) + :rtype: \CodeIgniter\Database\Forge - Adds a key to the set that will be used to create a table. Usage: See `Adding Keys`_. + Adds a key to the set that will be used to create a table. Usage: See `Adding Keys`_. - .. php:method:: addPrimaryKey($key) + .. php:method:: addPrimaryKey($key) - :param mixed $key: Name of a key field or an array of fields - :returns: \CodeIgniter\Database\Forge instance (method chaining) - :rtype: \CodeIgniter\Database\Forge + :param mixed $key: Name of a key field or an array of fields + :returns: \CodeIgniter\Database\Forge instance (method chaining) + :rtype: \CodeIgniter\Database\Forge - Adds a primary key to the set that will be used to create a table. Usage: See `Adding Keys`_. + Adds a primary key to the set that will be used to create a table. Usage: See `Adding Keys`_. - .. php:method:: addUniqueKey($key) + .. php:method:: addUniqueKey($key) - :param mixed $key: Name of a key field or an array of fields - :returns: \CodeIgniter\Database\Forge instance (method chaining) - :rtype: \CodeIgniter\Database\Forge + :param mixed $key: Name of a key field or an array of fields + :returns: \CodeIgniter\Database\Forge instance (method chaining) + :rtype: \CodeIgniter\Database\Forge - Adds a unique key to the set that will be used to create a table. Usage: See `Adding Keys`_. + Adds a unique key to the set that will be used to create a table. Usage: See `Adding Keys`_. - .. php:method:: createDatabase($dbName[, $ifNotExists = false]) + .. php:method:: createDatabase($dbName[, $ifNotExists = false]) - :param string $db_name: Name of the database to create - :param string $ifNotExists: Set to true to add an 'IF NOT EXISTS' clause or check if database exists - :returns: true on success, false on failure - :rtype: bool + :param string $db_name: Name of the database to create + :param string $ifNotExists: Set to true to add an 'IF NOT EXISTS' clause or check if database exists + :returns: true on success, false on failure + :rtype: bool - Creates a new database. Usage: See `Creating and Dropping Databases`_. + Creates a new database. Usage: See `Creating and Dropping Databases`_. - .. php:method:: createTable($table[, $if_not_exists = false[, array $attributes = []]]) + .. php:method:: createTable($table[, $if_not_exists = false[, array $attributes = []]]) - :param string $table: Name of the table to create - :param string $if_not_exists: Set to true to add an 'IF NOT EXISTS' clause - :param string $attributes: An associative array of table attributes - :returns: Query object on success, false on failure - :rtype: mixed + :param string $table: Name of the table to create + :param string $if_not_exists: Set to true to add an 'IF NOT EXISTS' clause + :param string $attributes: An associative array of table attributes + :returns: Query object on success, false on failure + :rtype: mixed - Creates a new table. Usage: See `Creating a table`_. + Creates a new table. Usage: See `Creating a table`_. - .. php:method:: dropColumn($table, $column_name) + .. php:method:: dropColumn($table, $column_name) - :param string $table: Table name - :param mixed $column_names: Comma-delimited string or an array of column names - :returns: true on success, false on failure - :rtype: bool + :param string $table: Table name + :param mixed $column_names: Comma-delimited string or an array of column names + :returns: true on success, false on failure + :rtype: bool - Drops single or multiple columns from a table. Usage: See `Dropping Columns From a Table`_. + Drops single or multiple columns from a table. Usage: See `Dropping Columns From a Table`_. - .. php:method:: dropDatabase($dbName) + .. php:method:: dropDatabase($dbName) - :param string $dbName: Name of the database to drop - :returns: true on success, false on failure - :rtype: bool + :param string $dbName: Name of the database to drop + :returns: true on success, false on failure + :rtype: bool - Drops a database. Usage: See `Creating and Dropping Databases`_. + Drops a database. Usage: See `Creating and Dropping Databases`_. - .. php:method:: dropTable($table_name[, $if_exists = false]) + .. php:method:: dropTable($table_name[, $if_exists = false]) - :param string $table: Name of the table to drop - :param string $if_exists: Set to true to add an 'IF EXISTS' clause - :returns: true on success, false on failure - :rtype: bool + :param string $table: Name of the table to drop + :param string $if_exists: Set to true to add an 'IF EXISTS' clause + :returns: true on success, false on failure + :rtype: bool - Drops a table. Usage: See `Dropping a table`_. + Drops a table. Usage: See `Dropping a table`_. - .. php:method:: modifyColumn($table, $field) + .. php:method:: modifyColumn($table, $field) - :param string $table: Table name - :param array $field: Column definition(s) - :returns: true on success, false on failure - :rtype: bool + :param string $table: Table name + :param array $field: Column definition(s) + :returns: true on success, false on failure + :rtype: bool - Modifies a table column. Usage: See `Modifying a Column in a Table`_. + Modifies a table column. Usage: See `Modifying a Column in a Table`_. - .. php:method:: renameTable($table_name, $new_table_name) + .. php:method:: renameTable($table_name, $new_table_name) - :param string $table: Current of the table - :param string $new_table_name: New name of the table - :returns: Query object on success, false on failure - :rtype: mixed + :param string $table: Current of the table + :param string $new_table_name: New name of the table + :returns: Query object on success, false on failure + :rtype: mixed - Renames a table. Usage: See `Renaming a table`_. + Renames a table. Usage: See `Renaming a table`_. diff --git a/user_guide_src/source/dbmgmt/migration.rst b/user_guide_src/source/dbmgmt/migration.rst index 9da3e2d3e9b3..027c12d3dc70 100644 --- a/user_guide_src/source/dbmgmt/migration.rst +++ b/user_guide_src/source/dbmgmt/migration.rst @@ -43,41 +43,41 @@ migrations go in the **app/Database/Migrations/** directory and have names such as *20121031100537_add_blog.php*. :: - forge->addField([ - 'blog_id' => [ - 'type' => 'INT', - 'constraint' => 5, - 'unsigned' => true, - 'auto_increment' => true, - ], - 'blog_title' => [ - 'type' => 'VARCHAR', - 'constraint' => '100', - ], - 'blog_description' => [ - 'type' => 'TEXT', - 'null' => true, - ], - ]); - $this->forge->addKey('blog_id', true); - $this->forge->createTable('blog'); - } - - public function down() - { - $this->forge->dropTable('blog'); - } - } + forge->addField([ + 'blog_id' => [ + 'type' => 'INT', + 'constraint' => 5, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'blog_title' => [ + 'type' => 'VARCHAR', + 'constraint' => '100', + ], + 'blog_description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + ]); + $this->forge->addKey('blog_id', true); + $this->forge->createTable('blog'); + } + + public function down() + { + $this->forge->dropTable('blog'); + } + } The database connection and the database Forge class are both available to you through ``$this->db`` and ``$this->forge``, respectively. @@ -94,14 +94,14 @@ To temporarily bypass the foreign key checks while running migrations, use the ` :: - public function up() - { - $this->db->disableForeignKeyChecks() + public function up() + { + $this->db->disableForeignKeyChecks() - // Migration rules would go here.. + // Migration rules would go here.. - $this->db->enableForeignKeyChecks(); - } + $this->db->enableForeignKeyChecks(); + } Database Groups =============== @@ -114,26 +114,26 @@ another database is used for mission critical data. You can ensure that migratio against the proper group by setting the ``$DBGroup`` property on your migration. This name must match the name of the database group exactly:: - APPPATH, - 'MyCompany' => ROOTPATH . 'MyCompany', - ]; + $psr4 = [ + 'App' => APPPATH, + 'MyCompany' => ROOTPATH . 'MyCompany', + ]; This will look for any migrations located at both **APPPATH/Database/Migrations** and **ROOTPATH/MyCompany/Database/Migrations**. This makes it simple to include migrations in your @@ -164,23 +164,23 @@ Usage Example In this example some simple code is placed in **app/Controllers/Migrate.php** to update the schema:: - latest(); - } catch (\Throwable $e) { - // Do something with the error here... - } - } - } + try { + $migrate->latest(); + } catch (\Throwable $e) { + // Do something with the error here... + } + } + } ******************* Command-Line Tools @@ -258,8 +258,11 @@ creates is the Pascal case version of the filename. You can use (make:migration) with the following options: -- ``-n`` - to choose namespace, otherwise the value of ``APP_NAMESPACE`` will be used. -- ``-force`` - If a similarly named migration file is present in destination, this will be overwritten. +- ``--session`` - Generates the migration file for database sessions. +- ``--table`` - Table name to use for database sessions. Default: ``ci_sessions``. +- ``--dbgroup`` - Database group to use for database sessions. Default: ``default``. +- ``--namespace`` - Set root namespace. Default: ``APP_NAMESPACE``. +- ``--suffix`` - Append the component title to the class name. ********************* Migration Preferences @@ -281,62 +284,63 @@ Class Reference .. php:class:: CodeIgniter\\Database\\MigrationRunner - .. php:method:: findMigrations() + .. php:method:: findMigrations() - :returns: An array of migration files - :rtype: array + :returns: An array of migration files + :rtype: array - An array of migration filenames are returned that are found in the **path** property. + An array of migration filenames are returned that are found in the **path** property. - .. php:method:: latest($group) + .. php:method:: latest($group) - :param mixed $group: database group name, if null default database group will be used. - :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :param mixed $group: database group name, if null default database group will be used. + :returns: ``true`` on success, ``false`` on failure + :rtype: bool - This locates migrations for a namespace (or all namespaces), determines which migrations - have not yet been run, and runs them in order of their version (namespaces intermingled). + This locates migrations for a namespace (or all namespaces), determines which migrations + have not yet been run, and runs them in order of their version (namespaces intermingled). - .. php:method:: regress($batch, $group) + .. php:method:: regress($batch, $group) - :param mixed $batch: previous batch to migrate down to; 1+ specifies the batch, 0 reverts all, negative refers to the relative batch (e.g., -3 means "three batches back") - :param mixed $group: database group name, if null default database group will be used. - :returns: ``true`` on success, ``false`` on failure or no migrations are found - :rtype: bool + :param mixed $batch: previous batch to migrate down to; 1+ specifies the batch, 0 reverts all, negative refers to the relative batch (e.g., -3 means "three batches back") + :param mixed $group: database group name, if null default database group will be used. + :returns: ``true`` on success, ``false`` on failure or no migrations are found + :rtype: bool - Regress can be used to roll back changes to a previous state, batch by batch. - :: + Regress can be used to roll back changes to a previous state, batch by batch. + :: - $migration->regress(5); - $migration->regress(-1); + $migration->regress(5); + $migration->regress(-1); - .. php:method:: force($path, $namespace, $group) + .. php:method:: force($path, $namespace, $group) - :param mixed $path: path to a valid migration file. - :param mixed $namespace: namespace of the provided migration. - :param mixed $group: database group name, if null default database group will be used. - :returns: ``true`` on success, ``false`` on failure - :rtype: bool + :param mixed $path: path to a valid migration file. + :param mixed $namespace: namespace of the provided migration. + :param mixed $group: database group name, if null default database group will be used. + :returns: ``true`` on success, ``false`` on failure + :rtype: bool - This forces a single file to migrate regardless of order or batches. Method "up" or "down" is detected based on whether it has already been migrated. + This forces a single file to migrate regardless of order or batches. Method "up" or "down" is detected based on whether it has already been migrated. - .. note:: This method is recommended only for testing and could cause data consistency issues. + .. note:: This method is recommended only for testing and could cause data consistency issues. - .. php:method:: setNamespace($namespace) + .. php:method:: setNamespace($namespace) - :param string $namespace: application namespace. - :returns: The current MigrationRunner instance - :rtype: CodeIgniter\\Database\\MigrationRunner + :param string $namespace: application namespace. + :returns: The current MigrationRunner instance + :rtype: CodeIgniter\\Database\\MigrationRunner - Sets the namespace the library should look for migration files:: + Sets the namespace the library should look for migration files:: - $migration->setNamespace($namespace)->latest(); - .. php:method:: setGroup($group) + $migration->setNamespace($namespace)->latest(); - :param string $group: database group name. - :returns: The current MigrationRunner instance - :rtype: CodeIgniter\\Database\\MigrationRunner + .. php:method:: setGroup($group) - Sets the group the library should look for migration files:: + :param string $group: database group name. + :returns: The current MigrationRunner instance + :rtype: CodeIgniter\\Database\\MigrationRunner - $migration->setGroup($group)->latest(); + Sets the group the library should look for migration files:: + + $migration->setGroup($group)->latest(); diff --git a/user_guide_src/source/dbmgmt/seeds.rst b/user_guide_src/source/dbmgmt/seeds.rst index 53a2635ed782..95afc23104b7 100644 --- a/user_guide_src/source/dbmgmt/seeds.rst +++ b/user_guide_src/source/dbmgmt/seeds.rst @@ -13,28 +13,28 @@ connection and the forge through ``$this->db`` and ``$this->forge``, respectivel stored within the **app/Database/Seeds** directory. The name of the file must match the name of the class. :: - 'darth', - 'email' => 'darth@theempire.com' - ]; + class SimpleSeeder extends Seeder + { + public function run() + { + $data = [ + 'username' => 'darth', + 'email' => 'darth@theempire.com' + ]; - // Simple Queries - $this->db->query("INSERT INTO users (username, email) VALUES(:username:, :email:)", $data); + // Simple Queries + $this->db->query("INSERT INTO users (username, email) VALUES(:username:, :email:)", $data); - // Using Query Builder - $this->db->table('users')->insert($data); - } - } + // Using Query Builder + $this->db->table('users')->insert($data); + } + } Nesting Seeders =============== @@ -42,30 +42,30 @@ Nesting Seeders Seeders can call other seeders, with the **call()** method. This allows you to easily organize a central seeder, but organize the tasks into separate seeder files:: - call('UserSeeder'); - $this->call('CountrySeeder'); - $this->call('JobSeeder'); - } - } + class TestSeeder extends Seeder + { + public function run() + { + $this->call('UserSeeder'); + $this->call('CountrySeeder'); + $this->call('JobSeeder'); + } + } You can also use a fully-qualified class name in the **call()** method, allowing you to keep your seeders anywhere the autoloader can find them. This is great for more modular code bases:: - public function run() - { - $this->call('UserSeeder'); - $this->call('My\Database\Seeds\CountrySeeder'); - } + public function run() + { + $this->call('UserSeeder'); + $this->call('My\Database\Seeds\CountrySeeder'); + } Using Faker =========== @@ -75,7 +75,7 @@ the `Faker library `_. To install Faker into your project:: - > composer require --dev fakerphp/faker + > composer require --dev fakerphp/faker After installation, an instance of ``Faker\Generator`` is available in the main ``Seeder`` class and is accessible by all child seeders. You must use the static method ``faker()`` @@ -83,32 +83,32 @@ to access the instance. :: - insert([ - 'email' => static::faker()->email, - 'ip_address' => static::faker()->ipv4, - ]); - } - } + $model->insert([ + 'email' => static::faker()->email, + 'ip_address' => static::faker()->ipv4, + ]); + } + } Using Seeders ============= You can grab a copy of the main seeder through the database config class:: - $seeder = \Config\Database::seeder(); - $seeder->call('TestSeeder'); + $seeder = \Config\Database::seeder(); + $seeder->call('TestSeeder'); Command Line Seeding -------------------- @@ -116,7 +116,7 @@ Command Line Seeding You can also seed data from the command line, as part of the Migrations CLI tools, if you don't want to create a dedicated controller:: - > php spark db:seed TestSeeder + > php spark db:seed TestSeeder Creating Seed Files ------------------- @@ -125,15 +125,13 @@ Using the command line, you can easily generate seed files. :: - // This command will create a UserSeeder seed file - // located at app/Database/Seeds/ directory. - > php spark make:seeder UserSeeder + > php spark make:seeder user --suffix + // Output: UserSeeder.php file located at app/Database/Seeds directory. -You can supply the **root** namespace where the seed file will be stored by supplying the ``-n`` option:: +You can supply the **root** namespace where the seed file will be stored by supplying the ``--namespace`` option:: - > php spark make:seeder MySeeder -n Acme\Blog + > php spark make:seeder MySeeder --namespace Acme\Blog -If ``Acme\Blog`` is mapped to ``app/Blog`` directory, then this command will save the -seed file to ``app/Blog/Database/Seeds/``. +If ``Acme\Blog`` is mapped to ``app/Blog`` directory, then this command will generate ``MySeeder.php`` at ``app/Blog/Database/Seeds`` directory. Supplying the ``--force`` option will overwrite existing files in destination. diff --git a/user_guide_src/source/extending/basecontroller.rst b/user_guide_src/source/extending/basecontroller.rst index 424329257fc1..bd5a76bf2386 100644 --- a/user_guide_src/source/extending/basecontroller.rst +++ b/user_guide_src/source/extending/basecontroller.rst @@ -6,16 +6,16 @@ CodeIgniter's core Controller should not be changed, but a default class extensi **app/Controllers/BaseController.php**. Any new controllers you make should extend ``BaseController`` to take advantage of preloaded components and any additional functionality you provide:: - session = \Config\Services::session(); - } + public function initController(...) + { + // Do Not Edit This Line + parent::initController($request, $response, $logger); + + $this->session = \Config\Services::session(); + } Additional Methods ================== @@ -54,7 +54,7 @@ the public controllers and make ``AdminController`` for any administrative contr If you do not want to use the base controller you may bypass it by having your controllers extend the system Controller instead:: - class Home extends \CodeIgniter\Controller - { - - } + class Home extends \CodeIgniter\Controller + { + + } diff --git a/user_guide_src/source/extending/contributing.rst b/user_guide_src/source/extending/contributing.rst index 67479baf439d..dcb7753cc4d2 100644 --- a/user_guide_src/source/extending/contributing.rst +++ b/user_guide_src/source/extending/contributing.rst @@ -7,66 +7,5 @@ and documentation from the community. These contributions are made in the form of Issues or `Pull Requests `_ on the `CodeIgniter4 repository `_ on GitHub. -Issues are a quick way to point out a bug. If you find a bug or documentation -error in CodeIgniter then please check a few things first: - -- There is not already an open Issue -- The issue has already been fixed (check the develop branch, or look for - closed Issues) -- Is it something really obvious that you fix it yourself? - -Reporting issues is helpful but an even better approach is to send a Pull -Request, which is done by "Forking" the main repository and committing to your -own copy. This will require you to use the version control system called Git. - -Please see the `Contributing to CodeIgniter4 `_ +If you'd like to contribute, please see the `Contributing to CodeIgniter4 `_ section of our code repository. - -******* -Support -******* - -Please note that GitHub is not for general support questions! If you are -having trouble using a feature, you can: - -- Start a new thread on the `forum `_ -- Ask your questions on `Slack `_ - -If you are not sure whether you are using something correctly or if you -have found a bug, again - please ask on the forums first. - -******** -Security -******** - -Did you find a security issue in CodeIgniter? - -Please *don't* disclose it publicly, but e-mail us at security@codeigniter.com, -or report it via our page on `HackerOne `_. - -If you've found a critical vulnerability, we'd be happy to credit you in our -`ChangeLog <../changelogs/index.html>`_. - -**************************** -Tips for a Good Issue Report -**************************** - -Use a descriptive subject line (eg parser library chokes on commas) rather than a vague one (e.g., your code broke). - -Address a single issue in a report. - -Identify the CodeIgniter version (eg 4.0.1) and the component if you know it (e.g., parser library) - -Explain what you expected to happen, and what did happen. -Include error messages and stack trace, if any. - -Include short code segments if they help to explain. -Use a pastebin or dropbox facility to include longer segments of code or screenshots - do not include them in the issue report itself. -This means setting a reasonable expiry for those until the issue is resolved or closed. - -If you know how to fix the issue, you can do so in your own fork & branch, and submit a pull request. -The issue report information above should be part of that. - -If your issue report can describe the steps to reproduce the problem, that is great. -If you can include a unit test that reproduces the problem, that is even better, as it gives whoever is fixing -it a clearer target! diff --git a/user_guide_src/source/extending/core_classes.rst b/user_guide_src/source/extending/core_classes.rst index 6d7b0c9a3b64..a51c9e825b34 100644 --- a/user_guide_src/source/extending/core_classes.rst +++ b/user_guide_src/source/extending/core_classes.rst @@ -60,16 +60,16 @@ the core system class, you would create your class like this:: // ... } -Then you would modify the ``routes`` service to load your class instead:: +Then you would add the ``routes`` service in **app/Config/Services.php** to load your class instead:: - public static function routes(bool $getShared = true) - { - if ($getShared) { - return static::getSharedInstance('routes'); - } + public static function routes(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('routes'); + } - return new RouteCollection(static::locator(), config('Modules')); - } + return new RouteCollection(static::locator(), config('Modules')); + } Extending Core Classes ====================== diff --git a/user_guide_src/source/extending/events.rst b/user_guide_src/source/extending/events.rst index f2190ee55473..79a8636b4bc7 100644 --- a/user_guide_src/source/extending/events.rst +++ b/user_guide_src/source/extending/events.rst @@ -23,29 +23,29 @@ Most events are defined within the **app/Config/Events.php** file. You can subsc the Events class' ``on()`` method. The first parameter is the name of the event to subscribe to. The second parameter is a callable that will be run when that event is triggered:: - use CodeIgniter\Events\Events; + use CodeIgniter\Events\Events; - Events::on('pre_system', ['MyClass', 'MyFunction']); + Events::on('pre_system', ['MyClass', 'MyFunction']); In this example, whenever the **pre_controller** event is executed, an instance of ``MyClass`` is created and the ``MyFunction`` method is run. Note that the second parameter can be *any* form of `callable `_ that PHP recognizes:: - // Call a standalone function - Events::on('pre_system', 'some_function'); + // Call a standalone function + Events::on('pre_system', 'some_function'); - // Call on an instance method - $user = new User(); - Events::on('pre_system', [$user, 'some_method']); + // Call on an instance method + $user = new User(); + Events::on('pre_system', [$user, 'some_method']); - // Call on a static method - Events::on('pre_system', 'SomeClass::someMethod'); + // Call on a static method + Events::on('pre_system', 'SomeClass::someMethod'); - // Use a Closure - Events::on('pre_system', function (...$params) - { - . . . - }); + // Use a Closure + Events::on('pre_system', function (...$params) + { + . . . + }); Setting Priorities ------------------ @@ -61,9 +61,9 @@ Any subscribers with the same priority will be executed in the order they were d Three constants are defined for your use, that set some helpful ranges on the values. You are not required to use these but you might find they aid readability:: - define('EVENT_PRIORITY_LOW', 200); - define('EVENT_PRIORITY_NORMAL', 100); - define('EVENT_PRIORITY_HIGH', 10); + define('EVENT_PRIORITY_LOW', 200); + define('EVENT_PRIORITY_NORMAL', 100); + define('EVENT_PRIORITY_HIGH', 10); Once sorted, all subscribers are executed in order. If any subscriber returns a boolean false value, then execution of the subscribers will stop. @@ -74,16 +74,16 @@ Publishing your own Events The Events library makes it simple for you to create events in your own code, also. To use this feature, you would simply need to call the ``trigger()`` method on the **Events** class with the name of the event:: - \CodeIgniter\Events\Events::trigger('some_event'); + \CodeIgniter\Events\Events::trigger('some_event'); You can pass any number of arguments to the subscribers by adding them as additional parameters. Subscribers will be given the arguments in the same order as defined:: - \CodeIgniter\Events\Events::trigger('some_events', $foo, $bar, $baz); + \CodeIgniter\Events\Events::trigger('some_events', $foo, $bar, $baz); - Events::on('some_event', function ($foo, $bar, $baz) { - ... - }); + Events::on('some_event', function ($foo, $bar, $baz) { + ... + }); Simulating Events ================= diff --git a/user_guide_src/source/general/ajax.rst b/user_guide_src/source/general/ajax.rst index 6324930b199b..710406b41272 100644 --- a/user_guide_src/source/general/ajax.rst +++ b/user_guide_src/source/general/ajax.rst @@ -50,4 +50,4 @@ React .. code-block:: javascript - axios.get("your url", {headers: {'Content-Type': 'application/json'}}) \ No newline at end of file + axios.get("your url", {headers: {'Content-Type': 'application/json'}}) diff --git a/user_guide_src/source/general/common_functions.rst b/user_guide_src/source/general/common_functions.rst index 8d006197bcfe..dd4c69a9915e 100755 --- a/user_guide_src/source/general/common_functions.rst +++ b/user_guide_src/source/general/common_functions.rst @@ -63,7 +63,7 @@ Service Accessors or return a default value if it is not found. Will format boolean values to actual booleans instead of string representations. - Especially useful when used in conjunction with .env files for setting + Especially useful when used in conjunction with **.env** files for setting values that are specific to the environment itself, like database settings, API keys, etc. diff --git a/user_guide_src/source/general/configuration.rst b/user_guide_src/source/general/configuration.rst index ee1486d91056..ac31e6f3241a 100644 --- a/user_guide_src/source/general/configuration.rst +++ b/user_guide_src/source/general/configuration.rst @@ -124,9 +124,9 @@ To save on typing, you can reuse variables that you've already specified in the variable name within ``${...}`` :: - BASE_DIR="/var/webroot/project-root" - CACHE_DIR="${BASE_DIR}/cache" - TMP_DIR="${BASE_DIR}/tmp" + BASE_DIR="/var/webroot/project-root" + CACHE_DIR="${BASE_DIR}/cache" + TMP_DIR="${BASE_DIR}/tmp" Namespaced Variables ==================== @@ -165,8 +165,7 @@ the configuration class properties are left unchanged. In this usage, the prefix the full (case-sensitive) namespace of the class. :: - Config\App.CSRFProtection = true - Config\App.CSRFCookieName = csrf_cookie + Config\App.forceGlobalSecureRequests = true Config\App.CSPEnabled = true @@ -177,25 +176,30 @@ the configuration class name. If the short prefix matches the class name, the value from **.env** replaces the configuration file value. :: - app.CSRFProtection = true - app.CSRFCookieName = csrf_cookie + app.forceGlobalSecureRequests = true app.CSPEnabled = true .. note:: When using the *short prefix* the property names must still exactly match the class defined name. +Some environments do not permit variable name with dots. In such case, you could also use ``_`` as a seperator. +:: + + app_forceGlobalSecureRequests = true + app_CSPEnabled = true + Environment Variables as Replacements for Data ============================================== It is very important to always remember that environment variables contained in your **.env** are -**only replacements for existing data**. This means that you cannot expect to fill your ``.env`` with all +**only replacements for existing data**. This means that you cannot expect to fill your **.env** with all the replacements for your configurations but have nothing to receive these replacements in the related configuration file(s). -The ``.env`` only serves to fill or replace the values in your configuration files. That said, your +The **.env** only serves to fill or replace the values in your configuration files. That said, your configuration files should have a container or receiving property for those. Adding so many variables in -your ``.env`` with nothing to contain them in the receiving end is useless. +your **.env** with nothing to contain them in the receiving end is useless. -Simply put, you cannot just put ``app.myNewConfig = foo`` in your ``.env`` and expect your ``Config\App`` +Simply put, you cannot just put ``app.myNewConfig = foo`` in your **.env** and expect your ``Config\App`` to magically have that property and value at run time. Treating Environment Variables as Arrays @@ -262,17 +266,17 @@ wish to supply an additional template to ``Pager`` without overwriting whatever already configured. In **src/Config/Registrar.php** there would be a ``Registrar`` class with the single ``Pager()`` method (note the case-sensitivity):: - class Registrar - { - public static function Pager(): array - { - return [ - 'templates' => [ - 'module_pager' => 'MyModule\Views\Pager', - ], - ]; - } - } + class Registrar + { + public static function Pager(): array + { + return [ + 'templates' => [ + 'module_pager' => 'MyModule\Views\Pager', + ], + ]; + } + } Registrar methods must always return an array, with keys corresponding to the properties of the target config file. Existing values are merged, and Registrar properties have @@ -337,4 +341,3 @@ by treating ``RegionalSales`` as a "registrar". The resulting configuration prop $target = 45; $campaign = "Winter Wonderland"; - diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst index 410e58236a8d..b7863b03010d 100644 --- a/user_guide_src/source/general/errors.rst +++ b/user_guide_src/source/general/errors.rst @@ -55,7 +55,7 @@ Configuration By default, CodeIgniter will display all errors in the ``development`` and ``testing`` environments, and will not display any errors in the ``production`` environment. You can change this by setting the ``CI_ENVIRONMENT`` variable -in the ``.env`` file. +in the **.env** file. .. important:: Disabling error reporting DOES NOT stop logs from being written if there are errors. diff --git a/user_guide_src/source/general/logging.rst b/user_guide_src/source/general/logging.rst index 6a6ca1e49e0e..7fa706ce6a86 100644 --- a/user_guide_src/source/general/logging.rst +++ b/user_guide_src/source/general/logging.rst @@ -38,7 +38,7 @@ Configuration ============= You can modify which levels are actually logged, as well as assign different Loggers to handle different levels, within -the ``/app/Config/Logger.php`` configuration file. +the **app/Config/Logger.php** configuration file. The ``threshold`` value of the config file determines which levels are logged across your application. If any levels are requested to be logged by the application, but the threshold doesn't allow them to log currently, they will be @@ -140,8 +140,8 @@ You can use any other logger that you might like as long as it extends from eith that you can easily drop in use for any PSR3-compatible logger, or create your own. You must ensure that the third-party logger can be found by the system, by adding it to either -the ``/app/Config/Autoload.php`` configuration file, or through another autoloader, -like Composer. Next, you should modify ``/app/Config/Services.php`` to point the ``logger`` +the **app/Config/Autoload.php** configuration file, or through another autoloader, +like Composer. Next, you should modify **app/Config/Services.php** to point the ``logger`` alias to your new class name. Now, any call that is done through the ``log_message()`` function will use your library instead. diff --git a/user_guide_src/source/general/modules.rst b/user_guide_src/source/general/modules.rst index b744f30c0441..91ec5554c955 100644 --- a/user_guide_src/source/general/modules.rst +++ b/user_guide_src/source/general/modules.rst @@ -73,7 +73,7 @@ would be used. Another approach provided by CodeIgniter is to autoload these *non-class* files like how you would autoload your classes. All we need to do is provide the list of paths to those files and include them in the -``$files`` property of your ``app/Config/Autoload.php`` file. +``$files`` property of your **app/Config/Autoload.php** file. :: @@ -101,7 +101,17 @@ This is configured in the file **app/Config/Modules.php**. The auto-discovery system works by scanning for particular directories and files within psr4 namespaces that have been defined in **Config/Autoload.php**. To make auto-discovery work for our **Blog** namespace, we need to make one small adjustment. -**Acme** needs to be changed to **Acme\\Blog** because each "module" within the namespace needs to be fully defined. Once your module folder path is defined, the discovery process would look for discoverable items on that path and should, for example, find the routes file at **/acme/Blog/Config/Routes.php**. +**Acme** needs to be changed to **Acme\\Blog** because each "module" within the namespace needs to be fully defined. + +:: + + public $psr4 = [ + APP_NAMESPACE => APPPATH, // For custom namespace + 'Config' => APPPATH . 'Config', + 'Acme\Blog' => ROOTPATH . 'acme/Blog', // Change + ]; + +Once your module folder path is defined, the discovery process would look for discoverable items on that path and should, for example, find the routes file at **/acme/Blog/Config/Routes.php**. Enable/Disable Discover ======================= @@ -143,6 +153,19 @@ the **Modules** config file, described above. .. note:: Since the files are being included into the current scope, the ``$routes`` instance is already defined for you. It will cause errors if you attempt to redefine that class. +Filters +======= + +By default, :doc:`filters ` are automatically scanned for within modules. +It can be turned off in the **Modules** config file, described above. + +.. note:: Since the files are being included into the current scope, the ``$filters`` instance is already defined for you. + It will cause errors if you attempt to redefine that class. + +In the module's **Config/Filters.php** file, you need to define the aliases of the filters you use.:: + + $filters->aliases['menus'] = MenusFilter::class; + Controllers =========== diff --git a/user_guide_src/source/helpers/cookie_helper.rst b/user_guide_src/source/helpers/cookie_helper.rst index 4175bb2b99c4..c7df02f6461f 100755 --- a/user_guide_src/source/helpers/cookie_helper.rst +++ b/user_guide_src/source/helpers/cookie_helper.rst @@ -22,16 +22,16 @@ The following functions are available: .. php:function:: set_cookie($name[, $value = ''[, $expire = ''[, $domain = ''[, $path = '/'[, $prefix = ''[, $secure = false[, $httpOnly = false[, $sameSite = '']]]]]]]]) - :param mixed $name: Cookie name *or* associative array of all of the parameters available to this function - :param string $value: Cookie value - :param int $expire: Number of seconds until expiration - :param string $domain: Cookie domain (usually: .yourdomain.com) - :param string $path: Cookie path - :param string $prefix: Cookie name prefix - :param bool $secure: Whether to only send the cookie through HTTPS - :param bool $httpOnly: Whether to hide the cookie from JavaScript - :param string $sameSite: The value for the SameSite cookie parameter. If null, the default from `config/App.php` is used - :rtype: void + :param mixed $name: Cookie name *or* associative array of all of the parameters available to this function + :param string $value: Cookie value + :param int $expire: Number of seconds until expiration + :param string $domain: Cookie domain (usually: .yourdomain.com) + :param string $path: Cookie path + :param string $prefix: Cookie name prefix + :param bool $secure: Whether to only send the cookie through HTTPS + :param bool $httpOnly: Whether to hide the cookie from JavaScript + :param string $sameSite: The value for the SameSite cookie parameter. If null, the default from `config/App.php` is used + :rtype: void This helper function gives you friendlier syntax to set browser cookies. Refer to the :doc:`Response Library ` for @@ -40,17 +40,17 @@ The following functions are available: .. php:function:: get_cookie($index[, $xssClean = false]) - :param string $index: Cookie name - :param bool $xss_clean: Whether to apply XSS filtering to the returned value - :returns: The cookie value or null if not found - :rtype: mixed + :param string $index: Cookie name + :param bool $xss_clean: Whether to apply XSS filtering to the returned value + :returns: The cookie value or null if not found + :rtype: mixed This helper function gives you friendlier syntax to get browser cookies. Refer to the :doc:`IncomingRequest Library ` for detailed description of its use, as this function acts very similarly to ``IncomingRequest::getCookie()``, except it will also prepend the ``$cookiePrefix`` that you might've set in your - *app/Config/App.php* file. + **app/Config/App.php** file. .. php:function:: delete_cookie($name[, $domain = ''[, $path = '/'[, $prefix = '']]]) diff --git a/user_guide_src/source/helpers/date_helper.rst b/user_guide_src/source/helpers/date_helper.rst index 0b7aeead3329..876a09bfbae6 100644 --- a/user_guide_src/source/helpers/date_helper.rst +++ b/user_guide_src/source/helpers/date_helper.rst @@ -22,9 +22,9 @@ The following functions are available: .. php:function:: now([$timezone = null]) - :param string $timezone: Timezone - :returns: UNIX timestamp - :rtype: int + :param string $timezone: Timezone + :returns: UNIX timestamp + :rtype: int Returns the current time as a UNIX timestamp, referenced either to your server's local time or any PHP supported timezone, based on the "time reference" setting @@ -41,12 +41,12 @@ The following functions are available: .. php:function:: timezone_select([$class = '', $default = '', $what = \DateTimeZone::ALL, $country = null]) - :param string $class: Optional class to apply to the select field - :param string $default: Default value for initial selection - :param int $what: DateTimeZone class constants (see `listIdentifiers `_) - :param string $country: A two-letter ISO 3166-1 compatible country code (see `listIdentifiers `_) - :returns: Preformatted HTML select field - :rtype: string + :param string $class: Optional class to apply to the select field + :param string $default: Default value for initial selection + :param int $what: DateTimeZone class constants (see `listIdentifiers `_) + :param string $country: A two-letter ISO 3166-1 compatible country code (see `listIdentifiers `_) + :returns: Preformatted HTML select field + :rtype: string Generates a `select` form field of available timezones (optionally filtered by `$what` and `$country`). You can supply an option class to apply to the field to make formatting easier, as well as a default diff --git a/user_guide_src/source/helpers/filesystem_helper.rst b/user_guide_src/source/helpers/filesystem_helper.rst index fc91a70f111e..2d884aeee08d 100644 --- a/user_guide_src/source/helpers/filesystem_helper.rst +++ b/user_guide_src/source/helpers/filesystem_helper.rst @@ -24,11 +24,11 @@ The following functions are available: .. php:function:: directory_map($source_dir[, $directory_depth = 0[, $hidden = false]]) - :param string $source_dir: Path to the source directory - :param int $directory_depth: Depth of directories to traverse (0 = fully recursive, 1 = current dir, etc) - :param bool $hidden: Whether to include hidden paths - :returns: An array of files - :rtype: array + :param string $source_dir: Path to the source directory + :param int $directory_depth: Depth of directories to traverse (0 = fully recursive, 1 = current dir, etc) + :param bool $hidden: Whether to include hidden paths + :returns: An array of files + :rtype: array Examples:: @@ -82,9 +82,9 @@ The following functions are available: .. php:function:: directory_mirror($original, $target[, $overwrite = true]) - :param string $original: Original source directory - :param string $target: Target destination directory - :param bool $overwrite: Whether individual files overwrite on collision + :param string $original: Original source directory + :param string $target: Target destination directory + :param bool $overwrite: Whether individual files overwrite on collision Recursively copies the files and directories of the origin directory into the target directory, i.e. "mirror" its contents. @@ -101,11 +101,11 @@ The following functions are available: .. php:function:: write_file($path, $data[, $mode = 'wb']) - :param string $path: File path - :param string $data: Data to write to file - :param string $mode: ``fopen()`` mode - :returns: true if the write was successful, false in case of an error - :rtype: bool + :param string $path: File path + :param string $data: Data to write to file + :param string $mode: ``fopen()`` mode + :returns: true if the write was successful, false in case of an error + :rtype: bool Writes data to the file specified in the path. If the file does not exist then the function will create it. @@ -139,12 +139,12 @@ The following functions are available: .. php:function:: delete_files($path[, $delDir = false[, $htdocs = false[, $hidden = false]]]) - :param string $path: Directory path - :param bool $delDir: Whether to also delete directories - :param bool $htdocs: Whether to skip deleting .htaccess and index page files + :param string $path: Directory path + :param bool $delDir: Whether to also delete directories + :param bool $htdocs: Whether to skip deleting .htaccess and index page files :param bool $hidden: Whether to also delete hidden files (files beginning with a period) - :returns: true on success, false in case of an error - :rtype: bool + :returns: true on success, false in case of an error + :rtype: bool Deletes ALL files contained in the supplied path. @@ -163,11 +163,11 @@ The following functions are available: .. php:function:: get_filenames($source_dir[, $include_path = false]) - :param string $source_dir: Directory path - :param bool|null $include_path: Whether to include the path as part of the filename; false for no path, null for the path relative to $source_dir, true for the full path - :param bool $hidden: Whether to include hidden files (files beginning with a period) - :returns: An array of file names - :rtype: array + :param string $source_dir: Directory path + :param bool|null $include_path: Whether to include the path as part of the filename; false for no path, null for the path relative to $source_dir, true for the full path + :param bool $hidden: Whether to include hidden files (files beginning with a period) + :returns: An array of file names + :rtype: array Takes a server path as input and returns an array containing the names of all files contained within it. The file path can optionally be added to the file names by setting @@ -180,10 +180,10 @@ The following functions are available: .. php:function:: get_dir_file_info($source_dir, $top_level_only) - :param string $source_dir: Directory path - :param bool $top_level_only: Whether to look only at the specified directory (excluding sub-directories) - :returns: An array containing info on the supplied directory's contents - :rtype: array + :param string $source_dir: Directory path + :param bool $top_level_only: Whether to look only at the specified directory (excluding sub-directories) + :returns: An array containing info on the supplied directory's contents + :rtype: array Reads the specified directory and builds an array containing the filenames, filesize, dates, and permissions. Sub-folders contained within the specified path are only read @@ -196,10 +196,10 @@ The following functions are available: .. php:function:: get_file_info($file[, $returned_values = ['name', 'server_path', 'size', 'date']]) - :param string $file: File path - :param array|string $returned_values: What type of info to return to be passed as array or comma separated string - :returns: An array containing info on the specified file or false on failure - :rtype: array + :param string $file: File path + :param array|string $returned_values: What type of info to return to be passed as array or comma separated string + :returns: An array containing info on the specified file or false on failure + :rtype: array Given a file and path, returns (optionally) the *name*, *path*, *size* and *date modified* information attributes for a file. Second parameter allows you to explicitly declare what @@ -210,9 +210,9 @@ The following functions are available: .. php:function:: symbolic_permissions($perms) - :param int $perms: Permissions - :returns: Symbolic permissions string - :rtype: string + :param int $perms: Permissions + :returns: Symbolic permissions string + :rtype: string Takes numeric permissions (such as is returned by ``fileperms()``) and returns standard symbolic notation of file permissions. @@ -223,9 +223,9 @@ The following functions are available: .. php:function:: octal_permissions($perms) - :param int $perms: Permissions - :returns: Octal permissions string - :rtype: string + :param int $perms: Permissions + :returns: Octal permissions string + :rtype: string Takes numeric permissions (such as is returned by ``fileperms()``) and returns a three character octal notation of file permissions. @@ -236,10 +236,10 @@ The following functions are available: .. php:function:: same_file($file1, $file2) - :param string $file1: Path to the first file - :param string $file2: Path to the second file - :returns: Whether both files exist with identical hashes - :rtype: boolean + :param string $file1: Path to the first file + :param string $file2: Path to the second file + :returns: Whether both files exist with identical hashes + :rtype: boolean Compares two files to see if they are the same (based on their MD5 hash). @@ -249,10 +249,10 @@ The following functions are available: .. php:function:: set_realpath($path[, $check_existence = false]) - :param string $path: Path - :param bool $check_existence: Whether to check if the path actually exists - :returns: An absolute path - :rtype: string + :param string $path: Path + :param bool $check_existence: Whether to check if the path actually exists + :returns: An absolute path + :rtype: string This function will return a server path without symbolic links or relative directory structures. An optional second argument will @@ -264,12 +264,12 @@ The following functions are available: echo set_realpath($file); // Prints '/etc/php5/apache2/php.ini' $non_existent_file = '/path/to/non-exist-file.txt'; - echo set_realpath($non_existent_file, true); // Shows an error, as the path cannot be resolved - echo set_realpath($non_existent_file, false); // Prints '/path/to/non-exist-file.txt' + echo set_realpath($non_existent_file, true); // Shows an error, as the path cannot be resolved + echo set_realpath($non_existent_file, false); // Prints '/path/to/non-exist-file.txt' $directory = '/etc/php5'; - echo set_realpath($directory); // Prints '/etc/php5/' + echo set_realpath($directory); // Prints '/etc/php5/' $non_existent_directory = '/path/to/nowhere'; - echo set_realpath($non_existent_directory, true); // Shows an error, as the path cannot be resolved - echo set_realpath($non_existent_directory, false); // Prints '/path/to/nowhere' + echo set_realpath($non_existent_directory, true); // Shows an error, as the path cannot be resolved + echo set_realpath($non_existent_directory, false); // Prints '/path/to/nowhere' diff --git a/user_guide_src/source/helpers/form_helper.rst b/user_guide_src/source/helpers/form_helper.rst index f54b1166a05e..e8788de0a499 100644 --- a/user_guide_src/source/helpers/form_helper.rst +++ b/user_guide_src/source/helpers/form_helper.rst @@ -48,11 +48,11 @@ The following functions are available: .. php:function:: form_open([$action = ''[, $attributes = ''[, $hidden = []]]]) - :param string $action: Form action/target URI string - :param mixed $attributes: HTML attributes, as an array or escaped string - :param array $hidden: An array of hidden fields' definitions - :returns: An HTML form opening tag - :rtype: string + :param string $action: Form action/target URI string + :param mixed $attributes: HTML attributes, as an array or escaped string + :param array $hidden: An array of hidden fields' definitions + :returns: An HTML form opening tag + :rtype: string Creates an opening form tag with a site URL **built from your config preferences**. It will optionally let you add form attributes and hidden input fields, and @@ -103,7 +103,9 @@ The following functions are available: will return::
- + + + .. note:: To use auto-generation of CSRF field, you need to turn CSRF filter on to the form page. In most cases it is requested using the ``GET`` method. **Adding Hidden Input Fields** @@ -123,11 +125,11 @@ The following functions are available: .. php:function:: form_open_multipart([$action = ''[, $attributes = ''[, $hidden = []]]]) - :param string $action: Form action/target URI string - :param mixed $attributes: HTML attributes, as an array or escaped string - :param array $hidden: An array of hidden fields' definitions - :returns: An HTML multipart form opening tag - :rtype: string + :param string $action: Form action/target URI string + :param mixed $attributes: HTML attributes, as an array or escaped string + :param array $hidden: An array of hidden fields' definitions + :returns: An HTML multipart form opening tag + :rtype: string This function is identical to :php:func:`form_open()` above, except that it adds a *multipart* attribute, which is necessary if you @@ -135,10 +137,10 @@ The following functions are available: .. php:function:: form_hidden($name[, $value = '']) - :param string $name: Field name - :param string $value: Field value - :returns: An HTML hidden input field tag - :rtype: string + :param string $name: Field name + :param string $value: Field value + :returns: An HTML hidden input field tag + :rtype: string Lets you generate hidden input fields. You can either submit a name/value string to create one field:: @@ -202,12 +204,12 @@ The following functions are available: .. php:function:: form_input([$data = ''[, $value = ''[, $extra = ''[, $type = 'text']]]]) - :param array $data: Field attributes data - :param string $value: Field value - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :param array $data: Field attributes data + :param string $value: Field value + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string :param string $type: The type of input field. i.e., 'text', 'email', 'number', etc. - :returns: An HTML text input field tag - :rtype: string + :returns: An HTML text input field tag + :rtype: string Lets you generate a standard text input field. You can minimally pass the field name and value in the first and second parameter:: @@ -257,22 +259,22 @@ The following functions are available: .. php:function:: form_password([$data = ''[, $value = ''[, $extra = '']]]) - :param array $data: Field attributes data - :param string $value: Field value - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML password input field tag - :rtype: string + :param array $data: Field attributes data + :param string $value: Field value + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML password input field tag + :rtype: string This function is identical in all respects to the :php:func:`form_input()` function above except that it uses the "password" input type. .. php:function:: form_upload([$data = ''[, $value = ''[, $extra = '']]]) - :param array $data: Field attributes data - :param string $value: Field value - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML file upload input field tag - :rtype: string + :param array $data: Field attributes data + :param string $value: Field value + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML file upload input field tag + :rtype: string This function is identical in all respects to the :php:func:`form_input()` function above except that it uses the "file" input type, allowing it to @@ -280,11 +282,11 @@ The following functions are available: .. php:function:: form_textarea([$data = ''[, $value = ''[, $extra = '']]]) - :param array $data: Field attributes data - :param string $value: Field value - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML textarea tag - :rtype: string + :param array $data: Field attributes data + :param string $value: Field value + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML textarea tag + :rtype: string This function is identical in all respects to the :php:func:`form_input()` function above except that it generates a "textarea" type. @@ -294,12 +296,12 @@ The following functions are available: .. php:function:: form_dropdown([$name = ''[, $options = [][, $selected = [][, $extra = '']]]]) - :param string $name: Field name - :param array $options: An associative array of options to be listed - :param array $selected: List of fields to mark with the *selected* attribute - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML dropdown select field tag - :rtype: string + :param string $name: Field name + :param array $options: An associative array of options to be listed + :param array $selected: List of fields to mark with the *selected* attribute + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML dropdown select field tag + :rtype: string Lets you create a standard drop-down field. The first parameter will contain the name of the field, the second parameter will contain an @@ -365,12 +367,12 @@ The following functions are available: .. php:function:: form_multiselect([$name = ''[, $options = [][, $selected = [][, $extra = '']]]]) - :param string $name: Field name - :param array $options: An associative array of options to be listed - :param array $selected: List of fields to mark with the *selected* attribute - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML dropdown multiselect field tag - :rtype: string + :param string $name: Field name + :param array $options: An associative array of options to be listed + :param array $selected: List of fields to mark with the *selected* attribute + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML dropdown multiselect field tag + :rtype: string Lets you create a standard multiselect field. The first parameter will contain the name of the field, the second parameter will contain an @@ -383,10 +385,10 @@ The following functions are available: .. php:function:: form_fieldset([$legend_text = ''[, $attributes = []]]) - :param string $legend_text: Text to put in the tag - :param array $attributes: Attributes to be set on the
tag - :returns: An HTML fieldset opening tag - :rtype: string + :param string $legend_text: Text to put in the tag + :param array $attributes: Attributes to be set on the
tag + :returns: An HTML fieldset opening tag + :rtype: string Lets you generate fieldset/legend fields. @@ -409,8 +411,8 @@ The following functions are available: second parameter if you prefer to set additional attributes:: $attributes = [ - 'id' => 'address_info', - 'class' => 'address_info' + 'id' => 'address_info', + 'class' => 'address_info' ]; echo form_fieldset('Address Information', $attributes); @@ -428,9 +430,9 @@ The following functions are available: .. php:function:: form_fieldset_close([$extra = '']) - :param string $extra: Anything to append after the closing tag, *as is* - :returns: An HTML fieldset closing tag - :rtype: string + :param string $extra: Anything to append after the closing tag, *as is* + :returns: An HTML fieldset closing tag + :rtype: string Produces a closing
tag. The only advantage to using this function is it permits you to pass data to it which will be added below @@ -444,12 +446,12 @@ The following functions are available: .. php:function:: form_checkbox([$data = ''[, $value = ''[, $checked = false[, $extra = '']]]]) - :param array $data: Field attributes data - :param string $value: Field value - :param bool $checked: Whether to mark the checkbox as being *checked* - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML checkbox input tag - :rtype: string + :param array $data: Field attributes data + :param string $value: Field value + :param bool $checked: Whether to mark the checkbox as being *checked* + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML checkbox input tag + :rtype: string Lets you generate a checkbox field. Simple example:: @@ -487,23 +489,23 @@ The following functions are available: .. php:function:: form_radio([$data = ''[, $value = ''[, $checked = false[, $extra = '']]]]) - :param array $data: Field attributes data - :param string $value: Field value - :param bool $checked: Whether to mark the radio button as being *checked* - :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string - :returns: An HTML radio input tag - :rtype: string + :param array $data: Field attributes data + :param string $value: Field value + :param bool $checked: Whether to mark the radio button as being *checked* + :param mixed $extra: Extra attributes to be added to the tag either as an array or a literal string + :returns: An HTML radio input tag + :rtype: string This function is identical in all respects to the :php:func:`form_checkbox()` function above except that it uses the "radio" input type. .. php:function:: form_label([$label_text = ''[, $id = ''[, $attributes = []]]]) - :param string $label_text: Text to put in the