diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md index 7d35ae61782fa6be710f45d1661b4d7f64da444a..276d2235fc71fecfe96579939418a7b28f96d124 100644 --- a/.github/ISSUE_TEMPLATE/1_Bug_report.md +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md @@ -1,8 +1,11 @@ --- name: "Bug report" -about: 'Report a general framework issue. Please ensure your Laravel version is still supported: https://laravel.com/docs/releases#support-policy' +about: "Report something that's broken. Please ensure your Laravel version is still supported: https://laravel.com/docs/releases#support-policy" --- +<!-- DO NOT THROW THIS AWAY --> +<!-- Fill out the FULL versions with patch versions --> + - Laravel Version: #.#.# - PHP Version: #.#.# - Database Driver & Version: @@ -11,3 +14,6 @@ about: 'Report a general framework issue. Please ensure your Laravel version is ### Steps To Reproduce: + +<!-- If possible, please provide a GitHub repository to demonstrate your issue --> +<!-- laravel new bug-report --github="--public" --> diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 77d505230682b1ac6a1c5834ad8831fd6198c97a..3734d2e82ffbc4072c8eb447b527cabb196f421e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Feature request - url: https://github.com/laravel/ideas/issues - about: 'For ideas or feature requests, open up an issue on the Laravel ideas repository' - - name: Support question + url: https://github.com/laravel/framework/discussions + about: 'For ideas or feature requests, start a new discussion' + - name: Support Questions & Other url: https://laravel.com/docs/contributions#support-questions - about: 'This repository is only for reporting bugs. If you need help using the library, click:' + about: 'This repository is only for reporting bugs. If you have a question or need help using the library, click:' - name: Documentation issue url: https://github.com/laravel/docs about: For documentation issues, open a pull request at the laravel/docs repository diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 444b8e623703da9ca84c827188d8018e5277602b..b40190f6c04b55816b78da098462ad8e9ea4a58c 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -4,13 +4,7 @@ ## Supported Versions -Version | Security Fixes Until ---- | --- -6 (LTS) | September 3rd, 2022 -5.8 | February 26th, 2020 -5.7 | September 4th, 2019 -5.6 | February 7th, 2019 -5.5 (LTS) | August 30th, 2020 +Please see [our support policy](https://laravel.com/docs/releases#support-policy) for information on supported versions for security releases. ## Reporting a Vulnerability diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml new file mode 100644 index 0000000000000000000000000000000000000000..f23b92ea45b67d36f8f007d9536f9f1d6d931f0c --- /dev/null +++ b/.github/workflows/databases.yml @@ -0,0 +1,221 @@ +name: databases + +on: [push, pull_request] + +jobs: + mysql_57: + runs-on: ubuntu-20.04 + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: forge + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + strategy: + fail-fast: true + + name: MySQL 5.7 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit tests/Integration/Database --verbose + env: + DB_CONNECTION: mysql + DB_USERNAME: root + + mysql_8: + runs-on: ubuntu-20.04 + + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: forge + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + strategy: + fail-fast: true + + name: MySQL 8.0 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit tests/Integration/Database --verbose + env: + DB_CONNECTION: mysql + DB_USERNAME: root + + mariadb: + runs-on: ubuntu-20.04 + + services: + mysql: + image: mariadb:10 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: forge + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + strategy: + fail-fast: true + + name: MariaDB 10 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit tests/Integration/Database --verbose + env: + DB_CONNECTION: mysql + DB_USERNAME: root + + pgsql: + runs-on: ubuntu-20.04 + + services: + postgresql: + image: postgres:14 + env: + POSTGRES_DB: forge + POSTGRES_USER: forge + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3 + + strategy: + fail-fast: true + + name: PostgreSQL 14 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit tests/Integration/Database --verbose + env: + DB_CONNECTION: pgsql + DB_PASSWORD: password + + mssql: + runs-on: ubuntu-20.04 + + services: + sqlsrv: + image: mcr.microsoft.com/mssql/server:2019-latest + env: + ACCEPT_EULA: Y + SA_PASSWORD: Forge123 + ports: + - 1433:1433 + + strategy: + fail-fast: true + + name: SQL Server 2019 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: dom, curl, libxml, mbstring, zip, pcntl, sqlsrv, pdo, pdo_sqlsrv + tools: composer:v2 + coverage: none + + - name: Install dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit tests/Integration/Database --verbose --exclude-group SkipMSSQL + env: + DB_CONNECTION: sqlsrv + DB_DATABASE: master + DB_USERNAME: SA + DB_PASSWORD: Forge123 diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc2b76b7bc2ffb3d925739b20c8bebd4949b0bb0 --- /dev/null +++ b/.github/workflows/pull-requests.yml @@ -0,0 +1,13 @@ +name: pull requests + +on: + pull_request_target: + types: + - opened + +permissions: + pull-requests: write + +jobs: + uneditable: + uses: laravel/.github/.github/workflows/pull-requests.yml@main diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d2a1869044071477909bb0d35c9559bfba5b427b..2dc0267d735ceaf4eac41504ca11e48d9a23db3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ on: jobs: linux_tests: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 services: memcached: @@ -28,10 +28,15 @@ jobs: ports: - 6379:6379 options: --entrypoint redis-server + dynamodb: + image: amazon/dynamodb-local:latest + ports: + - 8888:8000 + strategy: fail-fast: true matrix: - php: [7.2, 7.3, 7.4, 8.0] + php: ['7.3', '7.4', '8.0', '8.1'] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} @@ -44,14 +49,15 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis-phpredis/phpredis@5.3.5, igbinary, msgpack, lzf, zstd, lz4, memcached + ini-values: error_reporting=E_ALL tools: composer:v2 coverage: none + env: + REDIS_CONFIGURE_OPTS: --enable-redis --enable-redis-igbinary --enable-redis-msgpack --enable-redis-lzf --with-liblzf --enable-redis-zstd --with-libzstd --enable-redis-lz4 --with-liblz4 + REDIS_LIBS: liblz4-dev, liblzf-dev, libzstd-dev - - name: Setup problem matchers - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Set Minimum Guzzle Version + - name: Set Minimum PHP 8.0 Versions uses: nick-invision/retry@v1 with: timeout_minutes: 5 @@ -59,6 +65,14 @@ jobs: command: composer require guzzlehttp/guzzle:^7.2 --no-interaction --no-update if: matrix.php >= 8 + - name: Set Minimum PHP 8.1 Versions + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require league/commonmark:^2.0.2 phpunit/phpunit:^9.5.8 ramsey/collection:^1.2 brick/math:^0.9.3 --no-interaction --no-update + if: matrix.php >= 8.1 + - name: Install dependencies uses: nick-invision/retry@v1 with: @@ -71,14 +85,26 @@ jobs: env: DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_USERNAME: root + DYNAMODB_CACHE_TABLE: laravel_dynamodb_test + DYNAMODB_ENDPOINT: "http://localhost:8888" + AWS_ACCESS_KEY_ID: random_key + AWS_SECRET_ACCESS_KEY: random_secret + + - name: Store artifacts + uses: actions/upload-artifact@v2 + with: + name: logs + path: | + vendor/orchestra/testbench-core/laravel/storage/logs + !vendor/**/.gitignore windows_tests: - runs-on: windows-latest + runs-on: windows-2019 strategy: fail-fast: true matrix: - php: [7.2, 7.3, 7.4, 8.0] + php: ['7.3', '7.4', '8.0', '8.1'] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} - Windows @@ -100,10 +126,7 @@ jobs: tools: composer:v2 coverage: none - - name: Setup problem matchers - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Set Minimum Guzzle Version + - name: Set Minimum PHP 8.0 Versions uses: nick-invision/retry@v1 with: timeout_minutes: 5 @@ -111,6 +134,14 @@ jobs: command: composer require guzzlehttp/guzzle:^7.2 --no-interaction --no-update if: matrix.php >= 8 + - name: Set Minimum PHP 8.1 Versions + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer require league/commonmark:^2.0.2 phpunit/phpunit:^9.5.8 --no-interaction --no-update + if: matrix.php >= 8.1 + - name: Install dependencies uses: nick-invision/retry@v1 with: @@ -120,3 +151,11 @@ jobs: - name: Execute tests run: vendor/bin/phpunit --verbose + + - name: Store artifacts + uses: actions/upload-artifact@v2 + with: + name: logs + path: | + vendor/orchestra/testbench-core/laravel/storage/logs + !vendor/**/.gitignore diff --git a/.styleci.yml b/.styleci.yml index 4b1218080728f653d427a03a90229e2f73363014..9cd91cf68fdca5390e6608b707e23cf4fcc059ea 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,5 +1,6 @@ php: preset: laravel + version: 8.1 js: finder: not-name: diff --git a/CHANGELOG-5.8.md b/CHANGELOG-5.8.md deleted file mode 100644 index 22a42a454162498a4c21248b57e44971a9e5d50b..0000000000000000000000000000000000000000 --- a/CHANGELOG-5.8.md +++ /dev/null @@ -1,581 +0,0 @@ -# Release Notes for 5.8.x - -## [Unreleased](https://github.com/laravel/framework/compare/v5.8.35...5.8) - - -## [v5.8.35 (2019-09-03)](https://github.com/laravel/framework/compare/v5.8.34...v5.8.35) - -### Added -- Added support of `NOT RLIKE` SQL operator ([#29788](https://github.com/laravel/framework/pull/29788)) -- Added hebrew letters to `Str:slug` language array ([#29838](https://github.com/laravel/framework/pull/29838), [ba772d6](https://github.com/laravel/framework/commit/ba772d643b88a4646c1161f5325e52de81d7a709)) -- Added support of `php7.4` ([#29842](https://github.com/laravel/framework/pull/29842)) - -### Fixed -- Fixed self-referencing `MorphOneOrMany` existence queries ([#29765](https://github.com/laravel/framework/pull/29765)) -- Fixed `QueueFake::size()` method ([#29761](https://github.com/laravel/framework/pull/29761), [ddaf6e6](https://github.com/laravel/framework/commit/ddaf6e63326263a9bb3732e887a2bf8b2381caa1)) - -### Changed -- Added note that the GD extension is required for generating images ([#29770](https://github.com/laravel/framework/pull/29770), [#29831](https://github.com/laravel/framework/pull/29831)) -- Changed `monolog/monolog` version to `^1.12` ([#29837](https://github.com/laravel/framework/pull/29837)) - - -## [v5.8.34 (2019-08-27)](https://github.com/laravel/framework/compare/v5.8.33...v5.8.34) - -### Fixed -- Fixed `MailMessage::render()` if `view` method was used ([#29698](https://github.com/laravel/framework/pull/29698)) -- Fixed setting of numeric values as model attribute ([#29714](https://github.com/laravel/framework/pull/29714)) -- Fixed mocking of events `until` method in `MocksApplicationServices` ([#29708](https://github.com/laravel/framework/pull/29708)) -- Fixed: Use custom attributes in lt/lte/gt/gte rules messages ([#29716](https://github.com/laravel/framework/pull/29716)) - -### Changed: -- Changed applying of Aws Instance Profile ([#29738](https://github.com/laravel/framework/pull/29738)) - - -## [v5.8.33 (2019-08-20)](https://github.com/laravel/framework/compare/v5.8.32...v5.8.33) - -### Added -- Added `ValidatesWhenResolvedTrait::passedValidation()` callback ([#29549](https://github.com/laravel/framework/pull/29549)) -- Implement new types for email validation support ([#29589](https://github.com/laravel/framework/pull/29589)) -- Added Redis 5 support ([#29606](https://github.com/laravel/framework/pull/29606)) -- Added `insertOrIgnore` support ([#29639](https://github.com/laravel/framework/pull/29639), [46d7e96](https://github.com/laravel/framework/commit/46d7e96ab3ab59339ef0ea8802963b2db84f9ab3), [#29645](https://github.com/laravel/framework/pull/29645)) -- Allowed to override the existing `Whoops` handler.([#29564](https://github.com/laravel/framework/pull/29564)) - -### Fixed -- Fixed non-displayable boolean values in validation messages ([#29560](https://github.com/laravel/framework/pull/29560)) -- Avoid undefined index errors when using AWS IAM ([#29565](https://github.com/laravel/framework/pull/29565)) -- Fixed exception message in the `ProviderRepository::writeManifest()` ([#29568](https://github.com/laravel/framework/pull/29568)) -- Fixed invalid link expiry count in ResetPassword ([#29579](https://github.com/laravel/framework/pull/29579)) -- Fixed command testing of `output` and `questions` expectations ([#29580](https://github.com/laravel/framework/pull/29580)) -- Added ignoring of classes which are not instantiable during event discovery ([#29587](https://github.com/laravel/framework/pull/29587)) -- Used real classname for seeders in the output ([#29601](https://github.com/laravel/framework/pull/29601)) - -### Refactoring -- Simplified `isset()` ([#29581](https://github.com/laravel/framework/pull/29581)) - - -## [v5.8.32 (2019-08-13)](https://github.com/laravel/framework/compare/v5.8.31...v5.8.32) - -### Fixed -- Fixed top level wildcard validation for `distinct` validator ([#29499](https://github.com/laravel/framework/pull/29499)) -- Fixed resolving of columns with schema references in Postgres ([#29448](https://github.com/laravel/framework/pull/29448)) -- Only remove the event mutex if it was created ([#29526](https://github.com/laravel/framework/pull/29526)) -- Fixed restoring serialized collection with deleted models ([#29533](https://github.com/laravel/framework/pull/29533), [74b62bb](https://github.com/laravel/framework/commit/74b62bbbb32674dfa167e2812231bf302454e67f)) - - -## [v5.8.31 (2019-08-06)](https://github.com/laravel/framework/compare/v5.8.30...v5.8.31) - -### Fixed -- Fixed FatalThrowableError in `updateExistingPivot()` when pivot is non-existent ([#29362](https://github.com/laravel/framework/pull/29362)) -- Fixed worker timeout handler when there is no job processing ([#29366](https://github.com/laravel/framework/pull/29366)) -- Fixed `assertJsonValidationErrors()` with muliple messages ([#29380](https://github.com/laravel/framework/pull/29380)) -- Fixed UPDATE queries with alias ([#29405](https://github.com/laravel/framework/pull/29405)) - -### Changed -- `Illuminate\Cache\ArrayStore::forget()` returns false on missing key ([#29427](https://github.com/laravel/framework/pull/29427)) -- Allow chaining on `QueryBuilder::dump()` method ([#29437](https://github.com/laravel/framework/pull/29437)) -- Change visibility to public for `hasPivotColumn()` method ([#29367](https://github.com/laravel/framework/pull/29367)) -- Added line break for plain text mails ([#29408](https://github.com/laravel/framework/pull/29408)) -- Use `date_create` to prevent date validator warnings ([#29342](https://github.com/laravel/framework/pull/29342), [#29389](https://github.com/laravel/framework/pull/29389)) - - -## [v5.8.30 (2019-07-30)](https://github.com/laravel/framework/compare/v5.8.29...v5.8.30) - -### Added -- Added `MakesHttpRequests::option()` and `MakesHttpRequests::optionJson()` methods ([#29258](https://github.com/laravel/framework/pull/29258)) -- Added `Blueprint::uuidMorphs()` and `Blueprint::nullableUuidMorphs()` methods ([#29289](https://github.com/laravel/framework/pull/29289)) -- Added `MailgunTransport::getEndpoint()` and `MailgunTransport::setEndpoint()` methods ([#29312](https://github.com/laravel/framework/pull/29312)) -- Added `WEBP` to image validation rule ([#29309](https://github.com/laravel/framework/pull/29309)) -- Added `TestResponse::assertSessionHasInput()` method ([#29327](https://github.com/laravel/framework/pull/29327)) -- Added support for custom redis driver ([#29275](https://github.com/laravel/framework/pull/29275)) -- Added Postgres support for `collation()` on columns ([#29213](https://github.com/laravel/framework/pull/29213)) - -### Fixed -- Fixed collections with JsonSerializable items and mixed values ([#29205](https://github.com/laravel/framework/pull/29205)) -- Fixed MySQL Schema Grammar `$modifiers` order ([#29265](https://github.com/laravel/framework/pull/29265)) -- Fixed UPDATE query bindings on PostgreSQL ([#29272](https://github.com/laravel/framework/pull/29272)) -- Fixed default theme for Markdown mails ([#29274](https://github.com/laravel/framework/pull/29274)) -- Fixed UPDATE queries with alias on SQLite ([#29276](https://github.com/laravel/framework/pull/29276)) -- Fixed UPDATE and DELETE queries with join bindings on PostgreSQL ([#29306](https://github.com/laravel/framework/pull/29306)) -- Fixed support of `DateTime` objects and `int` values in `orWhereDay()`, `orWhereMonth()`, `orWhereYear()` methods in the `Builder` ([#29317](https://github.com/laravel/framework/pull/29317)) -- Fixed DELETE queries with joins on PostgreSQL ([#29313](https://github.com/laravel/framework/pull/29313)) -- Prevented a job from firing if job marked as deleted ([#29204](https://github.com/laravel/framework/pull/29204), [1003c27](https://github.com/laravel/framework/commit/1003c27b73f11472c1ebdb9238b839aefddfb048)) -- Fixed model deserializing with custom `Model::newCollection()` ([#29196](https://github.com/laravel/framework/pull/29196)) - -### Reverted -- Reverted: [Added possibility for `WithFaker::makeFaker()` use local `app.faker_locale` config](https://github.com/laravel/framework/pull/29123) ([#29250](https://github.com/laravel/framework/pull/29250)) - -### Changed -- Allocate memory for error handling to allow handling memory exhaustion limits ([#29226](https://github.com/laravel/framework/pull/29226)) -- Teardown test suite after using fail() method ([#29267](https://github.com/laravel/framework/pull/29267)) - - -## [v5.8.29 (2019-07-16)](https://github.com/laravel/framework/compare/v5.8.28...v5.8.29) - -### Added -- Added possibility for `WithFaker::makeFaker()` use local `app.faker_locale` config ([#29123](https://github.com/laravel/framework/pull/29123)) -- Added ability to set theme for mail notifications ([#29132](https://github.com/laravel/framework/pull/29132)) -- Added runtime for each migration to output ([#29149](https://github.com/laravel/framework/pull/29149)) -- Added possibility for `whereNull` and `whereNotNull` to accept array columns argument ([#29154](https://github.com/laravel/framework/pull/29154)) -- Allowed `Console\Scheduling\ManagesFrequencies::hourlyAt()` to accept array of integers ([#29173](https://github.com/laravel/framework/pull/29173)) - -### Performance -- Improved eager loading performance for MorphTo relation ([#29129](https://github.com/laravel/framework/pull/29129)) - -### Fixed -- Fixed `Builder::whereDay()` and `Builder::whereMonth()` with raw expressions -- Fixed DELETE queries with alias on SQLite ([#29164](https://github.com/laravel/framework/pull/29164)) -- Fixed queue jobs using SerializesModels losing order of passed in collections ([#29136](https://github.com/laravel/framework/pull/29136)) -- Fixed conditional binding for nested optional dependencies ([#29180](https://github.com/laravel/framework/pull/29180)) -- Fixed: validator not failing on custom rule when message is null ([#29174](https://github.com/laravel/framework/pull/29174)) -- Fixed DELETE query bindings ([#29165](https://github.com/laravel/framework/pull/29165)) - - -## [v5.8.28 (2019-07-09)](https://github.com/laravel/framework/compare/v5.8.27...v5.8.28) - -### Added -- Make TestResponse tappable ([#29033](https://github.com/laravel/framework/pull/29033)) -- Added `Support\Collection::mergeRecursive()` method ([#29084](https://github.com/laravel/framework/pull/29084)) -- Added `Support\Collection::replace()` and `Support\Collection::replaceRecursive()` methods ([#29088](https://github.com/laravel/framework/pull/29088)) -- Added `Session\Store::only()` method ([#29107](https://github.com/laravel/framework/pull/29107)) - -### Fixed -- Fixed cache repository setMultiple with an iterator ([#29039](https://github.com/laravel/framework/pull/29039)) -- Fixed cache repository getMultiple implementation ([#29047](https://github.com/laravel/framework/pull/29047)) - -### Reverted -- Reverted [Fixed: app.stub for jquery components loading](https://github.com/laravel/framework/pull/29001) ([#29109](https://github.com/laravel/framework/pull/29109)) - -### Changed -- Fail job immediately after it timeouts if it wont be retried ([#29024](https://github.com/laravel/framework/pull/29024)) - - -## [v5.8.27 (2019-07-02)](https://github.com/laravel/framework/compare/v5.8.26...v5.8.27) - -### Added -- Let `mix` helper use `app.mix_url` config ([#28952](https://github.com/laravel/framework/pull/28952)) -- Added `RedisManager::setDriver()` method ([#28985](https://github.com/laravel/framework/pull/28985)) -- Added `whereHasMorph()` and corresponding methods to work with `MorphTo` relations ([#28928](https://github.com/laravel/framework/pull/28928)) - -### Fixed -- Fixed: Changing a database field to binary include `collation` ([#28975](https://github.com/laravel/framework/pull/28975)) -- Fixed [app.stub for jquery components loading](https://github.com/laravel/framework/issues/28984) ([#29001](https://github.com/laravel/framework/pull/29001)) -- Fixed equivalent for greek letter theta in `Str::ascii()` ([#28999](https://github.com/laravel/framework/pull/28999)) - -### Changed -- Prevented `TestResponse::dump()` and `TestResponse::dumpHeaders()` methods from ending execution of the script ([#28960](https://github.com/laravel/framework/pull/28960)) -- Allowed `TestResponse::dump()` and `TestResponse::dumpHeaders()` methods chaining ([#28967](https://github.com/laravel/framework/pull/28967)) -- Allowed to `NotificationFake` accept custom channels ([#28969](https://github.com/laravel/framework/pull/28969)) -- Replace contents of service manifest atomically ([#28973](https://github.com/laravel/framework/pull/28973)) -- Pass down the `serverVersion` database connection option to Doctrine DBAL connection ([#28964](https://github.com/laravel/framework/pull/28964), [1b55b28](https://github.com/laravel/framework/commit/1b55b289788d5c49187481e421d949fe409a27c1)) -- Replace `self::` with `static::` in the `Relation::getMorphedModel()` ([#28974](https://github.com/laravel/framework/pull/28974)) -- Set a message for `SuspiciousOperationException` ([#29000](https://github.com/laravel/framework/pull/29000)) -- Storing Mailgun Message-ID in the headers after sending ([#28994](https://github.com/laravel/framework/pull/28994)) - - -## [v5.8.26 (2019-06-25)](https://github.com/laravel/framework/compare/v5.8.25...v5.8.26) - -### Reverted -- Reverted: [Let `mix` helper use `app.asset_url`](https://github.com/laravel/framework/pull/28905) ([#28950](https://github.com/laravel/framework/pull/28950)) - - -## [v5.8.25 (2019-06-25)](https://github.com/laravel/framework/compare/v5.8.24...v5.8.25) - -### Added -- Added `json` option to `route:list` command ([#28894](https://github.com/laravel/framework/pull/28894)) - -### Fixed -- Fixed columns parameter on paginate method ([#28937](https://github.com/laravel/framework/pull/28937)) -- Prevent event cache from firing multiple times the same event(s) ([#28904](https://github.com/laravel/framework/pull/28904)) -- Fixed `TestResponse::assertJsonMissingValidationErrors()` on empty response ([#28595](https://github.com/laravel/framework/pull/28595), [#28913](https://github.com/laravel/framework/pull/28913)) -- Fixed percentage sign in filename fallback in the `FilesystemAdapter::response()` ([#28947](https://github.com/laravel/framework/pull/28947)) - -### Changed -- Allow `TestResponse::assertViewHas()` to see all data ([#28893](https://github.com/laravel/framework/pull/28893)) -- Let `mix` helper use `app.asset_url` ([#28905](https://github.com/laravel/framework/pull/28905)) - - -## [v5.8.24 (2019-06-19)](https://github.com/laravel/framework/compare/v5.8.23...v5.8.24) - -### Added -- Added possibility to assert that the session contains a given piece of data using a closure in `TestResponse::assertSessionHas()` ([#28837](https://github.com/laravel/framework/pull/28837)) -- Added `TestResponse::assertUnauthorized()` ([#28851](https://github.com/laravel/framework/pull/28851)) -- Allowed to define port in `ServeCommand` via `SERVER_PORT` env variable ([#28849](https://github.com/laravel/framework/pull/28849), [6a18e73](https://github.com/laravel/framework/commit/6a18e73f63f46b6aa5ab6faceb9eb5060c64fc15)) -- Allowed console environment argument to be separated with a space ([#28869](https://github.com/laravel/framework/pull/28869)) -- Added `@endcomponentFirst` directive ([#28884](https://github.com/laravel/framework/pull/28884)) -- Added optional parameter `$when` to `retry` helper ([85c0801](https://github.com/laravel/framework/commit/85c08016c424f6c8e45f08282523f8785eda9673)) - -### Fixed -- Fixed `Builder::dump()` and `Builder::dd()` with global scopes ([#28858](https://github.com/laravel/framework/pull/28858)) - -### Reverted -- Reverted: [Automatically bind the viewAny method to the index controller action](https://github.com/laravel/framework/pull/28820) ([#28865](https://github.com/laravel/framework/pull/28865)) - -### Changed -- Handle `SuspiciousOperationException` in router as `NotFoundHttpException` ([#28866](https://github.com/laravel/framework/pull/28866)) - - -## [v5.8.23 (2019-06-14)](https://github.com/laravel/framework/compare/v5.8.22...v5.8.23) - -### Fixed -- Fixed strict comparison in redis configuration Parsing. ([#28830](https://github.com/laravel/framework/pull/28830)) - -### Changed -- Improved support for arrays on `TestResponse::assertJsonValidationErrors()` ([2970dab](https://github.com/laravel/framework/commit/2970dab3944e3b37578fa193503aae4217c62e59)) - - -## [v5.8.22 (2019-06-12)](https://github.com/laravel/framework/compare/v5.8.21...v5.8.22) - -### Added -- Added `@componentFirst` directive ([#28783](https://github.com/laravel/framework/pull/28783)) -- Added support for typed eager loads ([#28647](https://github.com/laravel/framework/pull/28647), [d72e3cd](https://github.com/laravel/framework/commit/d72e3cd5be14dba654837466564018403839a5e9)) -- Added `Related` and `Recommended` to Pluralizer ([#28749](https://github.com/laravel/framework/pull/28749)) -- Added `Str::containsAll()` method ([#28806](https://github.com/laravel/framework/pull/28806)) -- Added: error handling for maintenance mode commands ([#28765](https://github.com/laravel/framework/pull/28765), [9e20849](https://github.com/laravel/framework/commit/9e20849e5cca7b98ebf0eee2b563b532ff6fe704)) -- Added message value assertion to `TestResponse::assertJsonValidationErrors()` ([#28787](https://github.com/laravel/framework/pull/28787)) -- Added: Automatically bind the viewAny method to the index controller action ([#28820](https://github.com/laravel/framework/pull/28820)) - -### Fixed -- Fixed database rules with where clauses ([#28748](https://github.com/laravel/framework/pull/28748)) -- Fixed: MorphTo Relation ignores parent $timestamp when touching ([#28670](https://github.com/laravel/framework/pull/28670)) -- Fixed: Sql Server issue during `dropAllTables` when foreign key constraints exist ([#28750](https://github.com/laravel/framework/pull/28750), [#28770](https://github.com/laravel/framework/pull/28770)) -- Fixed `Model::getConnectionName()` when `Model::cursor()` used ([#28804](https://github.com/laravel/framework/pull/28804)) - -### Changed -- Made `force` an optional feature when using `ConfirmableTrait`. ([#28742](https://github.com/laravel/framework/pull/28742)) -- Suggest resolution when no relationship value is returned in the `Model::getRelationshipFromMethod()` ([#28762](https://github.com/laravel/framework/pull/28762)) - - -## [v5.8.21 (2019-06-05)](https://github.com/laravel/framework/compare/v5.8.20...v5.8.21) - -### Fixed -- Fixed redis cluster connection parsing ([2bcb405](https://github.com/laravel/framework/commit/2bcb405ddc9ed69355513de5f2396dc658fd004d)) - - -## [v5.8.20 (2019-06-04)](https://github.com/laravel/framework/compare/v5.8.19...v5.8.20) - -### Added -- Added `viewAny()` to dummy policy class ([#28654](https://github.com/laravel/framework/pull/28654), [#28671](https://github.com/laravel/framework/pull/28671)) -- Added `fullpath` option to `make:migration` command ([#28669](https://github.com/laravel/framework/pull/28669)) - -### Performance improvement -- Improve performance for `Arr::collapse()` ([#28662](https://github.com/laravel/framework/pull/28662), [#28676](https://github.com/laravel/framework/pull/28676)) - -### Fixed -- Fixed `artisan cache:clear` command with a redis cluster using the Predis library ([#28706](https://github.com/laravel/framework/pull/28706)) - - -## [v5.8.19 (2019-05-28)](https://github.com/laravel/framework/compare/v5.8.18...v5.8.19) - -### Added -- Added optional `DYNAMODB_ENDPOINT` env variable to configure endpoint for DynamoDB ([#28600](https://github.com/laravel/framework/pull/28600)) -- Added `Illuminate\Foundation\Application::isProduction()` method ([#28602](https://github.com/laravel/framework/pull/28602)) -- Allowed exception reporting in `rescue()` to be disabled ([#28617](https://github.com/laravel/framework/pull/28617)) -- Allowed to parse Url in Redis configuration ([#28612](https://github.com/laravel/framework/pull/28612), [f4cfb32](https://github.com/laravel/framework/commit/f4cfb3287b358b41735072895a485f8e68c1c7f0)) -- Allowed setting additional (`sourceip` and `localdomain`) smtp config options ([#28631](https://github.com/laravel/framework/pull/28631), [435c05b](https://github.com/laravel/framework/commit/435c05b96a241d3d5e37ce524de9ea134714a9be)) - -### Fixed -- Fixed Eloquent UPDATE queries with alias ([#28607](https://github.com/laravel/framework/pull/28607)) -- Fixed `Illuminate\Cache\DynamoDbStore::forever()` ([#28618](https://github.com/laravel/framework/pull/28618)) -- Fixed `event:list` command, when using a combination of manually registering events and event auto discovering ([#28624](https://github.com/laravel/framework/pull/28624)) - -### Performance improvement -- Improve performance for `Arr::flatten()` ([#28614](https://github.com/laravel/framework/pull/28614)) - -### Changed -- Added `id` to `ModelNotFoundException` exception in `ImplicitRouteBinding` ([#28588](https://github.com/laravel/framework/pull/28588)) - - -## [v5.8.18 (2019-05-21)](https://github.com/laravel/framework/compare/v5.8.17...v5.8.18) - -### Added -- Added `html` as a new valid extension for views ([#28541](https://github.com/laravel/framework/pull/28541)) -- Added: provide notification callback `withSwiftMessage` in `MailMessage` ([#28535](https://github.com/laravel/framework/pull/28535)) - -### Fixed -- Fixed `Illuminate\Cache\FileStore::getPayload()` in case of broken cache ([#28536](https://github.com/laravel/framework/pull/28536)) -- Fixed exception: `The filename fallback must only contain ASCII characters` in the `Illuminate\Filesystem\FilesystemAdapter::response()` ([#28551](https://github.com/laravel/framework/pull/28551)) - -### Changed -- Make `Support\Testing\Fakes\MailFake::failures()` returns an empty array ([#28538](https://github.com/laravel/framework/pull/28538)) -- Make `Support\Testing\Fakes\BusFake::pipeThrough()` returns `$this` ([#28564](https://github.com/laravel/framework/pull/28564)) - -### Refactoring -- Cleanup html ([#28583](https://github.com/laravel/framework/pull/28583)) - - -## [v5.8.17 (2019-05-14)](https://github.com/laravel/framework/compare/v5.8.16...v5.8.17) - -### Added -- Added `Illuminate\Foundation\Testing\TestResponse::dumpHeaders()` ([#28450](https://github.com/laravel/framework/pull/28450)) -- Added `ends_with` validation rule ([#28455](https://github.com/laravel/framework/pull/28455)) -- Added possibility to use a few `columns` arguments in the `route:list` command ([#28459](https://github.com/laravel/framework/pull/28459)) -- Added `retryAfter` in `Mail\SendQueuedMailable` and `Notifications\SendQueuedNotifications` object ([#28484](https://github.com/laravel/framework/pull/28484)) -- Added `Illuminate\Foundation\Console\Kernel::scheduleCache()` ([6587e78](https://github.com/laravel/framework/commit/6587e78383c4ecc8d7f3791f54cf6f536a1fc089)) -- Added support for multiple `--path` options within migrate commands ([#28495](https://github.com/laravel/framework/pull/28495)) -- Added `Tappable` trait ([#28507](https://github.com/laravel/framework/pull/28507)) -- Added support auto-discovery for events in a custom application directory, that sets via `Illuminate\Foundation\Application::useAppPath()` ([#28493](https://github.com/laravel/framework/pull/28493)) -- Added passing of notifiable email through reset link ([#28475](https://github.com/laravel/framework/pull/28475)) -- Added support flush db on clusters in `PhpRedisConnection` and `PredisConnection` ([f4e8d5c](https://github.com/laravel/framework/commit/f4e8d5c1f1b72e24baac33c336233cca24230783)) - -### Fixed -- Fixed session resolver in `RoutingServiceProvider` (without bind of `session` in `Container`) ([#28438](https://github.com/laravel/framework/pull/28438)) -- Fixed `route:list` command when routes were dynamically modified ([#28460](https://github.com/laravel/framework/pull/28460), [#28463](https://github.com/laravel/framework/pull/28463)) -- Fixed `required` validation with multiple `passes()` calls ([#28502](https://github.com/laravel/framework/pull/28502)) -- Fixed the collation bug when changing columns in a migration ([#28514](https://github.com/laravel/framework/pull/28514)) -- Added password to the `RedisCluster` only if `redis` >= `4.3.0` ([1371940](https://github.com/laravel/framework/commit/1371940abe17b7b6008e136060fcf5534f15f03f)) -- Used `escapeshellarg` on windows symlink in `Filesystem::link()`([44c3feb](https://github.com/laravel/framework/commit/44c3feb604944599ad1c782a9942981c3991fa31)) - -### Changed -- Reset webpack file for none preset ([#28462](https://github.com/laravel/framework/pull/28462)) - - -## [v5.8.16 (2019-05-07)](https://github.com/laravel/framework/compare/v5.8.15...v5.8.16) - -### Added -- Added: Migration Events ([#28342](https://github.com/laravel/framework/pull/28342)) -- Added ability to drop types when running the `migrate:fresh` command ([#28382](https://github.com/laravel/framework/pull/28382)) -- Added `Renderable` functionality to `MailMessage` ([#28386](https://github.com/laravel/framework/pull/28386)) - -### Fixed -- Fixed the remaining issues with registering custom Doctrine types ([#28375](https://github.com/laravel/framework/pull/28375)) -- Fixed `fromSub()` and `joinSub()` with table prefix in `Query\Builder` ([#28400](https://github.com/laravel/framework/pull/28400)) -- Fixed false positives for `Schema::hasTable()` with views ([#28401](https://github.com/laravel/framework/pull/28401)) -- Fixed `sync` results with custom `Pivot` model ([#28416](https://github.com/laravel/framework/pull/28416), [e31d131](https://github.com/laravel/framework/commit/e31d13111da02fed6bd2ce7a6393431a4b34f924)) - -### Changed -- Modified `None` And `React` presets with `vue-template-compiler` ([#28389](https://github.com/laravel/framework/pull/28389)) -- Changed `navbar-laravel` class to `bg-white shadow-sm` class in `layouts\app.stub` ([#28417](https://github.com/laravel/framework/pull/28417)) -- Don't execute query in `Builder::findMany()` when ids are empty `Arrayable` ([#28432](https://github.com/laravel/framework/pull/28432)) -- Added parameter `password` for `RedisCluster` construct function ([#28434](https://github.com/laravel/framework/pull/28434)) -- Pass email verification URL to callback in `Auth\Notifications\VerifyEmail` ([#28428](https://github.com/laravel/framework/pull/28428)) -- Updated `RouteAction::parse()` ([#28397](https://github.com/laravel/framework/pull/28397)) -- Updated `Events\DiscoverEvents` ([#28421](https://github.com/laravel/framework/pull/28421), [#28426](https://github.com/laravel/framework/pull/28426)) - - -## [v5.8.15 (2019-04-27)](https://github.com/laravel/framework/compare/v5.8.14...v5.8.15) - -### Added -- Added handling of database URL as database connections ([#28308](https://github.com/laravel/framework/pull/28308), [4560d28](https://github.com/laravel/framework/commit/4560d28a8a5829253b3dea360c4fffb208962f83), [05b029e](https://github.com/laravel/framework/commit/05b029e58d545ee3489d45de01b8306ac0e6cf9e)) -- Added the `dd()` / `dump` methods to the `Illuminate\Database\Query\Builder.php` ([#28357](https://github.com/laravel/framework/pull/28357)) - -### Fixed -- Fixed `BelongsToMany` parent key ([#28317](https://github.com/laravel/framework/pull/28317)) -- Fixed `make:auth` command with apps configured views path ([#28324](https://github.com/laravel/framework/pull/28324), [e78cf02](https://github.com/laravel/framework/commit/e78cf0244d530b81e44c0249ded14512aaeb0ef9)) -- Fixed recursive replacements in `Str::replaceArray()` ([#28338](https://github.com/laravel/framework/pull/28338)) - -### Improved -- Added custom message to `TokenMismatchException` exception within `VerifyCsrfToken` class ([#28335](https://github.com/laravel/framework/pull/28335)) -- Improved output of `Foundation\Testing\TestResponse::assertSessionDoesntHaveErrors` when called with no arguments ([#28359](https://github.com/laravel/framework/pull/28359)) - -### Changed -- Allowed logging out other devices without setting remember me cookie ([#28366](https://github.com/laravel/framework/pull/28366)) - - -## [v5.8.14 (2019-04-23)](https://github.com/laravel/framework/compare/v5.8.13...v5.8.14) - -### Added -- Implemented `Job Based Retry Delay` ([#28265](https://github.com/laravel/framework/pull/28265)) - -### Changed -- Update auth stubs with `@error` blade directive ([#28273](https://github.com/laravel/framework/pull/28273)) -- Convert email data tables to layout tables ([#28286](https://github.com/laravel/framework/pull/28286)) - -### Reverted -- Partial reverted [ability of register custom Doctrine DBAL](https://github.com/laravel/framework/pull/28214), since of [#28282](https://github.com/laravel/framework/issues/28282) issue ([#28301](https://github.com/laravel/framework/pull/28301)) - -### Refactoring -- Replace code with `Null Coalescing Operator` ([#28280](https://github.com/laravel/framework/pull/28280), [#28287](https://github.com/laravel/framework/pull/28287)) - - -## [v5.8.13 (2019-04-18)](https://github.com/laravel/framework/compare/v5.8.12...v5.8.13) - -### Added -- Added `@error` blade directive ([#28062](https://github.com/laravel/framework/pull/28062)) -- Added the ability to register `custom Doctrine DBAL` types in the schema builder ([#28214](https://github.com/laravel/framework/pull/28214), [91a6afe](https://github.com/laravel/framework/commit/91a6afe1f9f8d18283f3ee9a72b636a121f06da5)) - -### Fixed -- Fixed: [Event::fake() does not replace dispatcher for guard](https://github.com/laravel/framework/issues/27451) ([#28238](https://github.com/laravel/framework/pull/28238), [be89773](https://github.com/laravel/framework/commit/be89773c52e7491de05dee053b18a38b177d6030)) - -### Reverted -- Reverted of [`possibility for use in / not in operators in the query builder`](https://github.com/laravel/framework/pull/28192) since of [issue with `wherePivot()` method](https://github.com/laravel/framework/issues/28251) ([04a547ee](https://github.com/laravel/framework/commit/04a547ee25f78ddd738610cdbda2cb393c6795e9)) - - -## [v5.8.12 (2019-04-16)](https://github.com/laravel/framework/compare/v5.8.11...v5.8.12) - -### Added -- Added `Illuminate\Support\Collection::duplicates()` ([#28181](https://github.com/laravel/framework/pull/28181)) -- Added `Illuminate\Database\Eloquent\Collection::duplicates()` ([#28194](https://github.com/laravel/framework/pull/28194)) -- Added `Illuminate\View\FileViewFinder::getViews()` ([#28198](https://github.com/laravel/framework/pull/28198)) -- Added helper methods `onSuccess()` \ `onFailure()` \ `pingOnSuccess()` \ `pingOnFailure()` \ `emailOnFailure()` to `Illuminate\Console\Scheduling\Event` ([#28167](https://github.com/laravel/framework/pull/28167)) -- Added `SET` datatype on MySQL Grammar ([#28171](https://github.com/laravel/framework/pull/28171)) -- Added possibility for use `in` / `not in` operators in the query builder ([#28192](https://github.com/laravel/framework/pull/28192)) - -### Fixed -- Fixed memory leak in JOIN queries ([#28220](https://github.com/laravel/framework/pull/28220)) -- Fixed circular dependency in `Support\Testing\Fakes\QueueFake` for undefined methods ([#28164](https://github.com/laravel/framework/pull/28164)) -- Fixed exception in `lt` \ `lte` \ `gt` \ `gte` validations with different types ([#28174](https://github.com/laravel/framework/pull/28174)) -- Fixed `string quoting` for `SQL Server` ([#28176](https://github.com/laravel/framework/pull/28176)) -- Fixed `whereDay` and `whereMonth` when passing `int` values ([#28185](https://github.com/laravel/framework/pull/28185)) - -### Changed -- Added `autocomplete` attributes to the html stubs ([#28226](https://github.com/laravel/framework/pull/28226)) -- Improved `event:list` command ([#28177](https://github.com/laravel/framework/pull/28177), [cde1c5d](https://github.com/laravel/framework/commit/cde1c5d8b38a9b040e70c344bba82781239a0bbf)) -- Updated `Illuminate\Database\Console\Factories\FactoryMakeCommand` to generate more IDE friendly code ([#28188](https://github.com/laravel/framework/pull/28188)) -- Added missing `LockProvider` interface on `DynamoDbStore` ([#28203](https://github.com/laravel/framework/pull/28203)) -- Change session's user_id to unsigned big integer in the stub ([#28206](https://github.com/laravel/framework/pull/28206)) - - -## [v5.8.11 (2019-04-10)](https://github.com/laravel/framework/compare/v5.8.10...v5.8.11) - -### Added -- Allowed to call `macros` directly on `Illuminate\Support\Facades\Date` ([#28129](https://github.com/laravel/framework/pull/28129)) -- Allowed `lock` to be configured in `local filesystems` ([#28124](https://github.com/laravel/framework/pull/28124)) -- Added tracking of the exit code in scheduled event commands ([#28140](https://github.com/laravel/framework/pull/28140)) - -### Fixed -- Fixed of escaping single quotes in json paths in `Illuminate\Database\Query\Grammars\Grammar` ([#28160](https://github.com/laravel/framework/pull/28160)) -- Fixed event discovery with different Application Namespace ([#28145](https://github.com/laravel/framework/pull/28145)) - -### Changed -- Added view path to end of compiled blade view (in case if path is not empty) ([#28117](https://github.com/laravel/framework/pull/28117), [#28141](https://github.com/laravel/framework/pull/28141)) -- Added `realpath` to `app_path` during string replacement in `Illuminate\Foundation\Console\Kernel::load()` ([82ded9a](https://github.com/laravel/framework/commit/82ded9a28621b552589aba66e4e05f9a46f46db6)) - -### Refactoring -- Refactoring of `Illuminate\Foundation\Events\DiscoverEvents::within()` ([#28122](https://github.com/laravel/framework/pull/28122), [006f999](https://github.com/laravel/framework/commit/006f999d8c629bf87ea0252447866a879d7d4a6e)) - - -## [v5.8.10 (2019-04-04)](https://github.com/laravel/framework/compare/v5.8.9...v5.8.10) - -### Added -- Added `replicating` model event ([#28077](https://github.com/laravel/framework/pull/28077)) -- Make `NotificationFake` macroable ([#28091](https://github.com/laravel/framework/pull/28091)) - -### Fixed -- Exclude non-existing directories from event discovery ([#28098](https://github.com/laravel/framework/pull/28098)) - -### Changed -- Sorting of events in `event:list` command ([3437751](https://github.com/laravel/framework/commit/343775115722ed0e6c3455b72ee7204aefdf37d3)) -- Removed path hint in compiled view ([33ce7bb](https://github.com/laravel/framework/commit/33ce7bbb6a7f536036b58b66cc760fbb9eda80de)) - - -## [v5.8.9 (2019-04-02)](https://github.com/laravel/framework/compare/v5.8.8...v5.8.9) - -### Added -- Added Event Discovery ([#28064](https://github.com/laravel/framework/pull/28064), [#28085](https://github.com/laravel/framework/pull/28085)) - -### Fixed -- Fixed serializing a collection from a `Resource` with `preserveKeys` property ([#27985](https://github.com/laravel/framework/pull/27985)) -- Fixed: `SoftDelete::runSoftDelete` and `SoftDelete::performDeleteOnModel` with overwritten `Model::setKeysForSaveQuery` ([#28081](https://github.com/laravel/framework/pull/28081)) - -### Changed -- Update forever cache duration for database driver from minutes to seconds ([#28048](https://github.com/laravel/framework/pull/28048)) - -### Refactoring: -- Refactoring of `Illuminate\Auth\Access\Gate::callBeforeCallbacks()` ([#28079](https://github.com/laravel/framework/pull/28079)) - - -## [v5.8.8 (2019-03-26)](https://github.com/laravel/framework/compare/v5.8.7...v5.8.8) - -### Added -- Added `Illuminate\Database\Query\Builder::forPageBeforeId()` method ([#28011](https://github.com/laravel/framework/pull/28011)) - -### Fixed -- Fixed `BelongsToMany::detach()` with custom pivot class ([#27997](https://github.com/laravel/framework/pull/27997)) -- Fixed incorrect event namespace in generated listener by `event:generate` command ([#28007](https://github.com/laravel/framework/pull/28007)) -- Fixed unique validation without ignored column ([#27987](https://github.com/laravel/framework/pull/27987)) - -### Changed -- Added `parameters` argument to `resolve` helper ([#28020](https://github.com/laravel/framework/pull/28020)) -- Don't add the path only if path is `empty` in compiled view ([#27976](https://github.com/laravel/framework/pull/27976)) - -### Refactoring -- Refactoring of `env()` helper ([#27965](https://github.com/laravel/framework/pull/27965)) - - -## [v5.8.6-v5.8.7 (2019-03-21)](https://github.com/laravel/framework/compare/v5.8.5...v5.8.7) - -### Fixed -- Fix: Locks acquired with block() are not immediately released if the callback fails ([#27957](https://github.com/laravel/framework/pull/27957)) - -### Changed -- Allowed retrieving `env` variables with `getenv()` ([#27958](https://github.com/laravel/framework/pull/27958), [c37702c](https://github.com/laravel/framework/commit/c37702cbdedd4e06eba2162d7a1be7d74362e0cf)) -- Used `stripslashes` for `Validation\Rules\Unique.php` ([#27940](https://github.com/laravel/framework/pull/27940), [34759cc](https://github.com/laravel/framework/commit/34759cc0e0e63c952d7f8b7580f48144a063c684)) - -### Refactoring -- Refactoring of `Illuminate\Http\Concerns::allFiles()` ([#27955](https://github.com/laravel/framework/pull/27955)) - - -## [v5.8.5 (2019-03-19)](https://github.com/laravel/framework/compare/v5.8.4...v5.8.5) - -### Added -- Added `Illuminate\Database\DatabaseManager::setReconnector()` ([#27845](https://github.com/laravel/framework/pull/27845)) -- Added `Illuminate\Auth\Access\Gate::none()` ([#27859](https://github.com/laravel/framework/pull/27859)) -- Added `OtherDeviceLogout` event ([#27865](https://github.com/laravel/framework/pull/27865), [5e87f2d](https://github.com/laravel/framework/commit/5e87f2df072ec4a243b6a3a983a753e8ffa5e6bf)) -- Added `even` and `odd` flags to the `Loop` variable in the `blade` ([#27883](https://github.com/laravel/framework/pull/27883)) - -### Changed -- Add replacement for lower danish `æ` ([#27886](https://github.com/laravel/framework/pull/27886)) -- Show error message from exception, if message exist for `403.blade.php` and `503.blade.php` error ([#27893](https://github.com/laravel/framework/pull/27893), [#27902](https://github.com/laravel/framework/pull/27902)) - -### Fixed -- Fixed seeding logic in `Arr::shuffle()` ([#27861](https://github.com/laravel/framework/pull/27861)) -- Fixed `Illuminate\Database\Query\Builder::updateOrInsert()` with empty `$values` ([#27906](https://github.com/laravel/framework/pull/27906)) -- Fixed `Application::getNamespace()` method ([#27915](https://github.com/laravel/framework/pull/27915)) -- Fixed of store previous url ([#27935](https://github.com/laravel/framework/pull/27935), [791992e](https://github.com/laravel/framework/commit/791992e20efdf043ac3c2d989025d48d648821de)) - -### Security -- Changed `Validation\Rules\Unique.php` ([da4d4a4](https://github.com/laravel/framework/commit/da4d4a468eee174bd619b4a04aab57e419d10ff4)). You can read more [here](https://blog.laravel.com/unique-rule-sql-injection-warning) - - -## [v5.8.4 (2019-03-12)](https://github.com/laravel/framework/compare/v5.8.3...v5.8.4) - -### Added -- Added `Illuminate\Support\Collection::join()` method ([#27723](https://github.com/laravel/framework/pull/27723)) -- Added `Illuminate\Foundation\Http\Kernel::getRouteMiddleware()` method ([#27852](https://github.com/laravel/framework/pull/27852)) -- Added danish specific transliteration to `Str` class ([#27857](https://github.com/laravel/framework/pull/27857)) - -### Fixed -- Fixed JSON boolean queries ([#27847](https://github.com/laravel/framework/pull/27847)) - - -## [v5.8.3 (2019-03-05)](https://github.com/laravel/framework/compare/v5.8.2...v5.8.3) - -### Added -- Added `Collection::countBy` ([#27770](https://github.com/laravel/framework/pull/27770)) -- Added protected `EloquentUserProvider::newModelQuery()` ([#27734](https://github.com/laravel/framework/pull/27734), [9bb7685](https://github.com/laravel/framework/commit/9bb76853403fcb071b9454f1dc0369a8b42c3257)) -- Added protected `StartSession::saveSession()` method ([#27771](https://github.com/laravel/framework/pull/27771), [76c7126](https://github.com/laravel/framework/commit/76c7126641e781fa30d819834f07149dda4e01e6)) -- Allow `belongsToMany` to take `Model/Pivot` class name as a second parameter ([#27774](https://github.com/laravel/framework/pull/27774)) - -### Fixed -- Fixed environment variable parsing ([#27706](https://github.com/laravel/framework/pull/27706)) -- Fixed guessed policy names when using `Gate::forUser` ([#27708](https://github.com/laravel/framework/pull/27708)) -- Fixed `via` as `string` in the `Notification` ([#27710](https://github.com/laravel/framework/pull/27710)) -- Fixed `StartSession` middleware ([499e4fe](https://github.com/laravel/framework/commit/499e4fefefc4f8c0fe6377297b575054ec1d476f)) -- Fixed `stack` channel's bug related to the `level` ([#27726](https://github.com/laravel/framework/pull/27726), [bc884bb](https://github.com/laravel/framework/commit/bc884bb30e3dc12545ab63cea1f5a74b33dab59c)) -- Fixed `email` validation for not string values ([#27735](https://github.com/laravel/framework/pull/27735)) - -### Changed -- Check if `MessageBag` is empty before checking keys exist in the `MessageBag` ([#27719](https://github.com/laravel/framework/pull/27719)) - - -## [v5.8.2 (2019-02-27)](https://github.com/laravel/framework/compare/v5.8.1...v5.8.2) - -### Fixed -- Fixed quoted environment variable parsing ([#27691](https://github.com/laravel/framework/pull/27691)) - - -## [v5.8.1 (2019-02-27)](https://github.com/laravel/framework/compare/v5.8.0...v5.8.1) - -### Added -- Added `Illuminate\View\FileViewFinder::setPaths()` ([#27678](https://github.com/laravel/framework/pull/27678)) - -### Changed -- Return fake objects from facades ([#27680](https://github.com/laravel/framework/pull/27680)) - -### Reverted -- reverted changes related to the `Facade` ([63d87d7](https://github.com/laravel/framework/commit/63d87d78e08cc502947f07ebbfa4993955339c5a)) - - -## [v5.8.0 (2019-02-26)](https://github.com/laravel/framework/compare/5.7...v5.8.0) - -Check the upgrade guide in the [Official Laravel Documentation](https://laravel.com/docs/5.8/upgrade). diff --git a/CHANGELOG-6.x.md b/CHANGELOG-6.x.md index 42350000b97c66e5d66a1763ead21d4a47fb2bb4..6e84a1314e1a1efad8764ae90a63b6b1adc8862b 100644 --- a/CHANGELOG-6.x.md +++ b/CHANGELOG-6.x.md @@ -1,6 +1,226 @@ # Release Notes for 6.x -## [Unreleased](https://github.com/laravel/framework/compare/v6.20.13...6.x) +## [Unreleased](https://github.com/laravel/framework/compare/v6.20.44...6.x) + + +## [v6.20.44 (2022-01-12)](https://github.com/laravel/framework/compare/v6.20.43...v6.20.44) + +### Fixed +- Fixed digits_between with fractions ([#40278](https://github.com/laravel/framework/pull/40278)) + + +## [v6.20.43 (2021-12-14)](https://github.com/laravel/framework/compare/v6.20.42...v6.20.43) + +### Fixed +- Fixed inconsistent escaping of artisan argument ([#39953](https://github.com/laravel/framework/pull/39953)) + +### Changed +- Do not return anything `Illuminate/Foundation/Application::afterLoadingEnvironment()` + + +## [v6.20.42 (2021-12-07)](https://github.com/laravel/framework/compare/v6.20.41...v6.20.42) + +### Fixed +- Fixed for dropping columns when using MSSQL as ([#39905](https://github.com/laravel/framework/pull/39905)) +- Fixed parent call in View ([#39908](https://github.com/laravel/framework/pull/39908)) + + +## [v6.20.41 (2021-11-23)](https://github.com/laravel/framework/compare/v6.20.40...v6.20.41) + +### Added +- Added phar to list of shouldBlockPhpUpload() in validator ([2d1f76a](https://github.com/laravel/framework/commit/2d1f76ab752ced011da05cf139799eab2a36ef90)) + + +## [v6.20.40 (2021-11-17)](https://github.com/laravel/framework/compare/v6.20.39...v6.20.40) + +### Fixed +- Fixes `Illuminate/Database/Query/Builder::limit()` to only cast integer when given other than null ([#39644](https://github.com/laravel/framework/pull/39644)) + + +## [v6.20.39 (2021-11-16)](https://github.com/laravel/framework/compare/v6.20.38...v6.20.39) + +### Fixed +- Fixed $value in `Illuminate/Database/Query/Builder::limit()` ([ddfa71e](https://github.com/laravel/framework/commit/ddfa71ee9f101394b4ff682471bc31a7ba6de5cf)) + + +## [v6.20.38 (2021-11-09)](https://github.com/laravel/framework/compare/v6.20.37...v6.20.38) + +### Added +- Added new lost connection error message for sqlsrv ([#39466](https://github.com/laravel/framework/pull/39466)) + + +## [v6.20.37 (2021-11-02)](https://github.com/laravel/framework/compare/v6.20.36...v6.20.37) + +### Fixed +- Fixed rate limiting unicode issue ([#39375](https://github.com/laravel/framework/pull/39375)) + + +## [v6.20.36 (2021-10-19)](https://github.com/laravel/framework/compare/v6.20.35...v6.20.36) + +### Fixed +- Add new lost connection message to DetectsLostConnections for Vapor ([#39209](https://github.com/laravel/framework/pull/39209)) + + +## [v6.20.35 (2021-10-05)](https://github.com/laravel/framework/compare/v6.20.34...v6.20.35) + +### Added +- Added new lost connection message to DetectsLostConnections ([#39028](https://github.com/laravel/framework/pull/39028)) + + +## [v6.20.34 (2021-09-07)](https://github.com/laravel/framework/compare/v6.20.33...v6.20.34) + +### Fixed +- Silence validator date parse warnings ([#38670](https://github.com/laravel/framework/pull/38670)) + + +## [v6.20.33 (2021-08-31)](https://github.com/laravel/framework/compare/v6.20.32...v6.20.33) + +### Changed +- Error out when detecting incompatible DBAL version ([#38543](https://github.com/laravel/framework/pull/38543)) + + +## [v6.20.32 (2021-08-10)](https://github.com/laravel/framework/compare/v6.20.31...v6.20.32) + +### Fixed +- Bump AWS PHP SDK ([#38297](https://github.com/laravel/framework/pull/38297)) + + +## [v6.20.31 (2021-08-03)](https://github.com/laravel/framework/compare/v6.20.30...v6.20.31) + +### Fixed +- Fixed signed routes with expires parameter ([#38111](https://github.com/laravel/framework/pull/38111), [732c0e0](https://github.com/laravel/framework/commit/732c0e0f64b222e7fc7daef6553f8e99007bb32c)) + +### Refactoring +- Remove hardcoded Carbon reference from scheduler event ([#38063](https://github.com/laravel/framework/pull/38063)) + + +## [v6.20.30 (2021-07-07)](https://github.com/laravel/framework/compare/v6.20.29...v6.20.30) + +### Fixed +- Fix edge case causing a BadMethodCallExceptions to be thrown when using loadMissing() ([#37871](https://github.com/laravel/framework/pull/37871)) + + +## [v6.20.29 (2021-06-22)](https://github.com/laravel/framework/compare/v6.20.28...v6.20.29) + +### Changed +- Removed unnecessary checks in RequiredIf validation, fixed tests ([#37700](https://github.com/laravel/framework/pull/37700)) + + +## [v6.20.28 (2021-06-15)](https://github.com/laravel/framework/compare/v6.20.27...v6.20.28) + +### Fixed +- Fixed dns_get_record loose check of A records for active_url rule ([#37675](https://github.com/laravel/framework/pull/37675)) +- Type hinted arguments for Illuminate\Validation\Rules\RequiredIf ([#37688](https://github.com/laravel/framework/pull/37688)) +- Fixed when passed object as parameters to scopes method ([#37692](https://github.com/laravel/framework/pull/37692)) + + +## [v6.20.27 (2021-05-11)](https://github.com/laravel/framework/compare/v6.20.26...v6.20.27) + +### Added +- Support mass assignment to SQL Server views ([#37307](https://github.com/laravel/framework/pull/37307)) + +### Fixed +- Fixed `Illuminate\Database\Query\Builder::offset()` with non numbers $value ([#37164](https://github.com/laravel/framework/pull/37164)) +- Fixed unless rules ([#37291](https://github.com/laravel/framework/pull/37291)) + +### Changed +- Allow reporting reportable exceptions with the default logger ([#37235](https://github.com/laravel/framework/pull/37235)) + + +## [v6.20.26 (2021-04-28)](https://github.com/laravel/framework/compare/v6.20.25...v6.20.26) + +### Fixed +- Fixed Cache store with a name other than 'dynamodb' ([#37145](https://github.com/laravel/framework/pull/37145)) + +### Changed +- Some cast to int in `Illuminate\Database\Query\Grammars\SqlServerGrammar` ([09bf145](https://github.com/laravel/framework/commit/09bf1457e9df53e172e6fd5929cbafb539677c7c)) + + +## [v6.20.25 (2021-04-27)](https://github.com/laravel/framework/compare/v6.20.24...v6.20.25) + +### Fixed +- Fixed nullable values for required_if ([#37128](https://github.com/laravel/framework/pull/37128), [86fd558](https://github.com/laravel/framework/commit/86fd558b4e5d8d7d45cf457cd1a72d54334297a1)) + + +## [v6.20.24 (2021-04-20)](https://github.com/laravel/framework/compare/v6.20.23...v6.20.24) + +### Fixed +- Fixed required_if boolean validation ([#36969](https://github.com/laravel/framework/pull/36969)) + + +## [v6.20.23 (2021-04-13)](https://github.com/laravel/framework/compare/v6.20.22...v6.20.23) + +### Added +- Added strings to the `DetectsLostConnections.php` ([4210258](https://github.com/laravel/framework/commit/42102589bc7f7b8533ee1b815ef0cc18017d4e45)) + + +## [v6.20.22 (2021-03-31)](https://github.com/laravel/framework/compare/v6.20.21...v6.20.22) + +### Fixed +- Fixed setting DynamoDB credentials ([#36822](https://github.com/laravel/framework/pull/36822)) + + +## [v6.20.21 (2021-03-30)](https://github.com/laravel/framework/compare/v6.20.20...v6.20.21) + +### Added +- Added support of DynamoDB in CI suite ([#36749](https://github.com/laravel/framework/pull/36749)) +- Support username parameter for predis ([#36762](https://github.com/laravel/framework/pull/36762)) + +### Changed +- Use qualified column names in pivot query ([#36720](https://github.com/laravel/framework/pull/36720)) + + +## [v6.20.20 (2021-03-23)](https://github.com/laravel/framework/compare/v6.20.19...v6.20.20) + +### Added +- Added WSREP communication link failure for lost connection detection ([#36668](https://github.com/laravel/framework/pull/36668)) + +### Fixed +- Fixes the issue using cache:clear with PhpRedis and a clustered Redis instance. ([#36665](https://github.com/laravel/framework/pull/36665)) + + +## [v6.20.19 (2021-03-16)](https://github.com/laravel/framework/compare/v6.20.18...v6.20.19) + +### Added +- Added broken pipe exception as lost connection error ([#36601](https://github.com/laravel/framework/pull/36601)) + + +## [v6.20.18 (2021-03-09)](https://github.com/laravel/framework/compare/v6.20.17...v6.20.18) + +### Fixed +- Fix validator treating null as true for (required|exclude)_(if|unless) due to loose `in_array()` check ([#36504](https://github.com/laravel/framework/pull/36504)) + +### Changed +- Delete existing links that are broken in `Illuminate\Foundation\Console\StorageLinkCommand` ([#36470](https://github.com/laravel/framework/pull/36470)) + + +## [v6.20.17 (2021-03-02)](https://github.com/laravel/framework/compare/v6.20.16...v6.20.17) + +### Added +- Added new line to `DetectsLostConnections` ([#36373](https://github.com/laravel/framework/pull/36373)) + + +## [v6.20.16 (2021-02-02)](https://github.com/laravel/framework/compare/v6.20.15...v6.20.16) + +### Fixed +- Fixed `Illuminate\View\ViewException::report()` ([#36110](https://github.com/laravel/framework/pull/36110)) +- Fixed `Illuminate\Redis\Connections\PhpRedisConnection::spop()` ([#36106](https://github.com/laravel/framework/pull/36106)) + +### Changed +- Typecast page number as integer in `Illuminate\Pagination\AbstractPaginator::resolveCurrentPage()` ([#36055](https://github.com/laravel/framework/pull/36055)) + + +## [v6.20.15 (2021-01-26)](https://github.com/laravel/framework/compare/v6.20.14...v6.20.15) + +### Changed +- Pipe new through render and report exception methods ([#36037](https://github.com/laravel/framework/pull/36037)) + + +## [v6.20.14 (2021-01-21)](https://github.com/laravel/framework/compare/v6.20.13...v6.20.14) + +### Fixed +- Fixed type error in `Illuminate\Http\Concerns\InteractsWithContentTypes::isJson()` ([#35956](https://github.com/laravel/framework/pull/35956)) +- Limit expected bindings ([#35972](https://github.com/laravel/framework/pull/35972), [006873d](https://github.com/laravel/framework/commit/006873df411d28bfd03fea5e7f91a2afe3918498)) ## [v6.20.13 (2021-01-19)](https://github.com/laravel/framework/compare/v6.20.12...v6.20.13) @@ -230,6 +450,8 @@ ### Changed - Improve cookie encryption ([#33662](https://github.com/laravel/framework/pull/33662)) +This change will invalidate all existing cookies. Please see [this security bulletin](https://blog.laravel.com/laravel-cookie-security-releases) for more information. + ## [v6.18.26 (2020-07-21)](https://github.com/laravel/framework/compare/v6.18.25...v6.18.26) diff --git a/CHANGELOG-8.x.md b/CHANGELOG-8.x.md new file mode 100644 index 0000000000000000000000000000000000000000..e50ff789ae279d3c87aa0c2d4ce1d516a9a7a03f --- /dev/null +++ b/CHANGELOG-8.x.md @@ -0,0 +1,2243 @@ +# Release Notes for 8.x + +## [Unreleased](https://github.com/laravel/framework/compare/v8.83.25...8.x) + + +## [v8.83.25 (2022-09-30)](https://github.com/laravel/framework/compare/v8.83.24...v8.83.25) + +### Added +- Added `Illuminate/Routing/Route::flushController()` ([#44393](https://github.com/laravel/framework/pull/44393)) + + +## [v8.83.24 (2022-09-22)](https://github.com/laravel/framework/compare/v8.83.23...v8.83.24) + +### Fixed +- Avoid Passing null to parameter exception on PHP 8.1 ([#43951](https://github.com/laravel/framework/pull/43951)) + +### Changed +- Patch for timeless timing attack vulnerability in user login ([#44069](https://github.com/laravel/framework/pull/44069)) + + +## [v8.83.23 (2022-07-26)](https://github.com/laravel/framework/compare/v8.83.22...v8.83.23) + +### Fixed +- Fix DynamoDB locks with 0 seconds duration ([#43365](https://github.com/laravel/framework/pull/43365)) + + +## [v8.83.22 (2022-07-22)](https://github.com/laravel/framework/compare/v8.83.21...v8.83.22) + +### Revert +- Revert ["Protect against ambiguous columns"](https://github.com/laravel/framework/pull/43278) ([#43362](https://github.com/laravel/framework/pull/43362)) + + +## [v8.83.21 (2022-07-21)](https://github.com/laravel/framework/compare/v8.83.20...v8.83.21) + +### Revert +- Revert of ["Prevent double throwing chained exception on sync queue"](https://github.com/laravel/framework/pull/42950) ([#43354](https://github.com/laravel/framework/pull/43354)) + + +## [v8.83.20 (2022-07-19)](https://github.com/laravel/framework/compare/v8.83.19...v8.83.20) + +### Fixed +- Fixed transaction attempts counter for sqlsrv ([#43176](https://github.com/laravel/framework/pull/43176)) + +### Changed +- Clear Facade resolvedInstances in queue worker resetScope callback ([#43215](https://github.com/laravel/framework/pull/43215)) +- Protect against ambiguous columns ([#43278](https://github.com/laravel/framework/pull/43278)) + + +## [v8.83.19 (2022-07-13)](https://github.com/laravel/framework/compare/v8.83.18...v8.83.19) + +### Fixed +- Fixed forceCreate on MorphMany not returning newly created object ([#42996](https://github.com/laravel/framework/pull/42996)) +- Prevent double throwing chained exception on sync queue ([#42950](https://github.com/laravel/framework/pull/42950)) + +### Changed +- Disable Column Statistics for php artisan schema:dump on MariaDB ([#43027](https://github.com/laravel/framework/pull/43027)) + + +## [v8.83.18 (2022-06-28)](https://github.com/laravel/framework/compare/v8.83.17...v8.83.18) + +### Fixed +- Fixed bug on forceCreate on a MorphMay relationship not including morph type ([#42929](https://github.com/laravel/framework/pull/42929)) +- Handle cursor paginator when no items are found ([#42963](https://github.com/laravel/framework/pull/42963)) +- Fixed Str::Mask() for repeating chars ([#42956](https://github.com/laravel/framework/pull/42956)) + + +## [v8.83.17 (2022-06-21)](https://github.com/laravel/framework/compare/v8.83.16...v8.83.17) + +### Added +- Apply where's from union query builder in cursor pagination ([#42651](https://github.com/laravel/framework/pull/42651)) +- Handle collection creation around a single enum ([#42839](https://github.com/laravel/framework/pull/42839)) + +### Fixed +- Fixed Request offsetExists without routeResolver ([#42754](https://github.com/laravel/framework/pull/42754)) +- Fixed: Loose comparison causes the value not to be saved ([#42793](https://github.com/laravel/framework/pull/42793)) + + +## [v8.83.16 (2022-06-07)](https://github.com/laravel/framework/compare/v8.83.15...v8.83.16) + +### Fixed +- Free reserved memory before handling fatal errors ([#42630](https://github.com/laravel/framework/pull/42630), [#42646](https://github.com/laravel/framework/pull/42646)) +- Prevent $mailer being reset when testing mailables that implement ShouldQueue ([#42695](https://github.com/laravel/framework/pull/42695)) + + +## [v8.83.15 (2022-05-31)](https://github.com/laravel/framework/compare/v8.83.14...v8.83.15) + +### Reverted +- Revert digits changes in Validator ([c6d1a2d](https://github.com/laravel/framework/commit/c6d1a2da17e3aaaeb0ff5b8cc4879816d214b527), [#42562](https://github.com/laravel/framework/pull/42562)) + +### Changed +- Retain the original attribute value during validation of an array key with a dot for correct failure message ([#42395](https://github.com/laravel/framework/pull/42395)) + + +## [v8.83.14 (2022-05-24)](https://github.com/laravel/framework/compare/v8.83.13...v8.83.14) + +### Fixed +- Add flush handler to output buffer for streamed test response (bugfix) ([#42481](https://github.com/laravel/framework/pull/42481)) + +### Changed +- Use duplicate instead of createFromBase to clone request when routes are cached ([#42420](https://github.com/laravel/framework/pull/42420)) + + +## [v8.83.13 (2022-05-17)](https://github.com/laravel/framework/compare/v8.83.12...v8.83.13) + +### Fixed +- Fix PruneCommand finding its usage within other traits ([#42350](https://github.com/laravel/framework/pull/42350)) + +### Changed +- Consistency between digits and digits_between validation rules ([#42358](https://github.com/laravel/framework/pull/42358)) +- Corrects the use of "failed_jobs" instead of "job_batches" in BatchedTableCommand ([#42389](https://github.com/laravel/framework/pull/42389)) + + +## [v8.83.12 (2022-05-10)](https://github.com/laravel/framework/compare/v8.83.11...v8.83.12) + +### Fixed +- Fixed multiple dots for digits_between rule ([#42330](https://github.com/laravel/framework/pull/42330)) + +### Changed +- Enable to modify HTTP Client request headers when using beforeSending() callback ([#42244](https://github.com/laravel/framework/pull/42244)) +- Set relation parent key when using forceCreate on HasOne and HasMany relations ([#42281](https://github.com/laravel/framework/pull/42281)) + + +## [v8.83.11 (2022-05-03)](https://github.com/laravel/framework/compare/v8.83.10...v8.83.11) + +### Fixed +- Fix refresh during down in the stub ([#42217](https://github.com/laravel/framework/pull/42217)) +- Fix deprecation issue with translator ([#42216](https://github.com/laravel/framework/pull/42216)) + + +## [v8.83.10 (2022-04-27)](https://github.com/laravel/framework/compare/v8.83.9...v8.83.10) + +### Fixed +- Fix schedule:work command Artisan binary name ([#42083](https://github.com/laravel/framework/pull/42083)) +- Fix array keys from cached routes in Illuminate/Routing/CompiledRouteCollection::getRoutesByMethod() ([#42078](https://github.com/laravel/framework/pull/42078)) +- Fix json_last_error issue with Illuminate/Http/JsonResponse::setData ([#42125](https://github.com/laravel/framework/pull/42125)) + + +## [v8.83.9 (2022-04-19)](https://github.com/laravel/framework/compare/v8.83.8...v8.83.9) + +### Fixed +- Backport Fix PHP warnings when rendering long blade string ([#41970](https://github.com/laravel/framework/pull/41970)) + + +## [v8.83.8 (2022-04-12)](https://github.com/laravel/framework/compare/v8.83.7...v8.83.8) + +### Added +- Added multibyte support to string padding helper functions ([#41899](https://github.com/laravel/framework/pull/41899)) + +### Fixed +- Fixed seeder property for in-memory tests ([#41869](https://github.com/laravel/framework/pull/41869)) + + +## [v8.83.7 (2022-04-05)](https://github.com/laravel/framework/compare/v8.83.6...v8.83.7) + +### Fixed +- Backport - Fix trashed implicitBinding with child with no softdelete ([#41814](https://github.com/laravel/framework/pull/41814)) +- Fix assertListening check with auto discovery ([#41820](https://github.com/laravel/framework/pull/41820)) + + +## [v8.83.6 (2022-03-29)](https://github.com/laravel/framework/compare/v8.83.5...v8.83.6) + +### Fixed +- Stop throwing LazyLoadingViolationException for recently created model instances ([#41549](https://github.com/laravel/framework/pull/41549)) +- Close doctrineConnection on disconnect ([#41584](https://github.com/laravel/framework/pull/41584)) +- Fix require fails if is_file cached by opcache ([#41614](https://github.com/laravel/framework/pull/41614)) +- Fix collection nth where step <= offset ([#41645](https://github.com/laravel/framework/pull/41645)) + + +## [v8.83.5 (2022-03-15)](https://github.com/laravel/framework/compare/v8.83.4...v8.83.5) + +### Fixed +- Backport dynamically access batch options ([#41361](https://github.com/laravel/framework/pull/41361)) +- Fixed get and head options in Illuminate/Http/Client/PendingRequest.php ([23ff879](https://github.com/laravel/framework/commit/23ff879c6e5c6c6424b09a8b38c1686a9c89c4a5)) + + +## [v8.83.4 (2022-03-08)](https://github.com/laravel/framework/compare/v8.83.3...v8.83.4) + +### Added +- Added `Illuminate/Bus/Batch::__get()` ([#41361](https://github.com/laravel/framework/pull/41361)) + +### Fixed +- Fixed get and head options in `Illuminate/Http/Client/PendingRequest` ([23ff879](https://github.com/laravel/framework/commit/23ff879c6e5c6c6424b09a8b38c1686a9c89c4a5)) + + +## [v8.83.3 (2022-03-03)](https://github.com/laravel/framework/compare/v8.83.2...v8.83.3) + +### Fixed +* $job can be an object in some methods by @villfa in https://github.com/laravel/framework/pull/41244 +* Fixes getting the trusted proxies IPs from the configuration file by @nunomaduro in https://github.com/laravel/framework/pull/41322 + + +## [v8.83.2 (2022-02-22)](https://github.com/laravel/framework/compare/v8.83.1...v8.83.2) + +### Added +- Added support of Bitwise opperators in query ([#41112](https://github.com/laravel/framework/pull/41112)) + +### Fixed +- Fixes attempt to log deprecations on mocks ([#41057](https://github.com/laravel/framework/pull/41057)) +- Fixed loadAggregate not correctly applying casts ([#41108](https://github.com/laravel/framework/pull/41108)) +- Fixed updated with provided qualified updated_at ([#41133](https://github.com/laravel/framework/pull/41133)) +- Fixed database migrations $connection property ([#41161](https://github.com/laravel/framework/pull/41161)) + + +## [v8.83.1 (2022-02-15)](https://github.com/laravel/framework/compare/v8.83.0...v8.83.1) + +### Added +- Add firstOr() function to BelongsToMany relation ([#40828](https://github.com/laravel/framework/pull/40828)) +- Catch suppressed deprecation logs ([#40942](https://github.com/laravel/framework/pull/40942)) +- Add doesntContain to higher order proxies ([#41034](https://github.com/laravel/framework/pull/41034)) + +### Fixed +- Fix replacing request options ([#40954](https://github.com/laravel/framework/pull/40954), [30e341b](https://github.com/laravel/framework/commit/30e341b7fe4e4d9019df42b7eff6c7dfa5ea30e5)) +- Fix isRelation() failing to check an Attribute ([#40967](https://github.com/laravel/framework/pull/40967)) +- Fix enum casts arrayable behaviour ([#40999](https://github.com/laravel/framework/pull/40999)) + + +## [v8.83.0 (2022-02-08)](https://github.com/laravel/framework/compare/v8.82.0...v8.83.0) + +### Added +* Add isolation level configuration for Postgres connector by @rezaamini-ir in https://github.com/laravel/framework/pull/40767 +* Add a string helper to swap multiple keywords in a string by @amitmerchant1990 in https://github.com/laravel/framework/pull/40831 & https://github.com/laravel/framework/commit/220f4ac11d462b4ee9ff2cb9b48b93d6f560223a + +### Changed +* Make `PendingRequest` `Conditionable` by @phillipfickl in https://github.com/laravel/framework/pull/40762 +* Add a BladeCompiler::renderComponent() method to render a component instance by @tobyzerner in https://github.com/laravel/framework/pull/40745 +* Doc block tweaks in `BladeCompiler.php` by @JayBizzle in https://github.com/laravel/framework/pull/40772 +* Revert Bit operators by @driesvints in https://github.com/laravel/framework/pull/40791 +* Improves `Support\Reflector` to support checking interfaces by @hassanhe in https://github.com/laravel/framework/pull/40822 +* Support cursor pagination with union query by @deleugpn in https://github.com/laravel/framework/pull/40848 +* Consistent `Stringable::swap()` & `Str::swap()` implementations by @derekmd in https://github.com/laravel/framework/pull/40855 + +### Fixed +* Do not set SYSTEMROOT to false by @Galaxy0419 in https://github.com/laravel/framework/pull/40819 + + +## [v8.82.0 (2022-02-01)](https://github.com/laravel/framework/compare/v8.81.0...v8.82.0) + +### Added +- Added class and method to create cross joined sequences for factories ([#40542](https://github.com/laravel/framework/pull/40542)) +- Added Transliterate shortcut to the Str helper ([#40681](https://github.com/laravel/framework/pull/40681)) +- Added array_keys validation rule ([#40720](https://github.com/laravel/framework/pull/40720)) + +### Fixed +- Prevent job serialization error in Queue ([#40625](https://github.com/laravel/framework/pull/40625)) +- Fixed autoresolving model name from factory ([#40616](https://github.com/laravel/framework/pull/40616)) +- Fixed : strtotime Epoch doesn't fit in PHP int ([#40690](https://github.com/laravel/framework/pull/40690)) +- Fixed Stringable ucsplit ([#40694](https://github.com/laravel/framework/pull/40694), [#40699](https://github.com/laravel/framework/pull/40699)) + +### Changed +- Server command: Allow xdebug auto-connect to listener feature ([#40673](https://github.com/laravel/framework/pull/40673)) +- respect null driver in `QueueServiceProvider` ([9435827](https://github.com/laravel/framework/commit/9435827014ca289213f2bcf64847f5c5959bb652), [56d433a](https://github.com/laravel/framework/commit/56d433aaec40e8383f28e8f0e835cd977845fcde)) +- Allow to push and prepend config values on new keys ([#40723](https://github.com/laravel/framework/pull/40723)) + + +## [v8.81.0 (2022-01-25)](https://github.com/laravel/framework/compare/v8.80.0...v8.81.0) + +### Added +- Added `Illuminate/Support/Stringable::scan()` ([#40472](https://github.com/laravel/framework/pull/40472)) +- Allow caching to be disabled for virtual attributes accessors that return an object ([#40519](https://github.com/laravel/framework/pull/40519)) +- Added better bitwise operators support ([#40529](https://github.com/laravel/framework/pull/40529), [def671d](https://github.com/laravel/framework/commit/def671d4902d9cdd315aee8249199b45fcc2186b)) +- Added getOrPut on Collection ([#40535](https://github.com/laravel/framework/pull/40535)) +- Improve PhpRedis flushing ([#40544](https://github.com/laravel/framework/pull/40544)) +- Added `Illuminate/Support/Str::flushCache()` ([#40620](https://github.com/laravel/framework/pull/40620)) + +### Fixed +- Fixed Str::headline/Str::studly with unicode and add Str::ucsplit method ([#40499](https://github.com/laravel/framework/pull/40499)) +- Fixed forgetMailers with MailFake ([#40495](https://github.com/laravel/framework/pull/40495)) +- Pruning Models: Get the default path for the models from a method instead ([#40539](https://github.com/laravel/framework/pull/40539)) +- Fix flushdb for predis cluste ([#40446](https://github.com/laravel/framework/pull/40446)) +- Avoid undefined array key 0 error ([#40571](https://github.com/laravel/framework/pull/40571)) + +### Changed +- Allow whitespace in PDO dbname for PostgreSQL ([#40483](https://github.com/laravel/framework/pull/40483)) +- Allows authorizeResource method to receive arrays of models and parameters ([#40516](https://github.com/laravel/framework/pull/40516)) +- Inverse morphable type and id filter statements to prevent SQL errors ([#40523](https://github.com/laravel/framework/pull/40523)) +- Bump voku/portable-ascii to v1.6.1 ([#40588](https://github.com/laravel/framework/pull/40588), [#40610](https://github.com/laravel/framework/pull/40610)) + + +## [v8.80.0 (2022-01-18)](https://github.com/laravel/framework/compare/v8.79.0...v8.80.0) + +### Added +- Allow enums as entity_type in morphs ([#40375](https://github.com/laravel/framework/pull/40375)) +- Added support for specifying a route group controller ([#40276](https://github.com/laravel/framework/pull/40276)) +- Added phpredis serialization and compression config support ([#40282](https://github.com/laravel/framework/pull/40282)) +- Added a BladeCompiler::render() method to render a string with Blade ([#40425](https://github.com/laravel/framework/pull/40425)) +- Added a method to sort keys in a collection using a callback ([#40458](https://github.com/laravel/framework/pull/40458)) + +### Changed +- Convert "/" in -e parameter to "\" in `Illuminate/Foundation/Console/ListenerMakeCommand` ([#40383](https://github.com/laravel/framework/pull/40383)) + +### Fixed +- Throws an error upon make:policy if no model class is configured ([#40348](https://github.com/laravel/framework/pull/40348)) +- Fix forwarded call with named arguments in `Illuminate/Filesystem/FilesystemAdapter` ([#40421](https://github.com/laravel/framework/pull/40421)) +- Fix 'strstr' function usage based on its signature ([#40457](https://github.com/laravel/framework/pull/40457)) + + +## [v8.79.0 (2022-01-12)](https://github.com/laravel/framework/compare/v8.78.1...v8.79.0) + +### Added +- Added onLastPage method to the Paginator ([#40265](https://github.com/laravel/framework/pull/40265)) +- Allow method typed variadics dependencies ([#40255](https://github.com/laravel/framework/pull/40255)) +- Added `ably/ably-php` to composer.json to suggest ([#40277](https://github.com/laravel/framework/pull/40277)) +- Implement Full-Text Search for MySQL & PostgreSQL ([#40129](https://github.com/laravel/framework/pull/40129)) +- Added whenContains and whenContainsAll to Stringable ([#40285](https://github.com/laravel/framework/pull/40285)) +- Support action_level configuration in LogManager ([#40305](https://github.com/laravel/framework/pull/40305)) +- Added whenEndsWith(), whenExactly(), whenStartsWith(), etc to Stringable ([#40320](https://github.com/laravel/framework/pull/40320)) +- Makes it easy to add additional options to PendingBatch ([#40333](https://github.com/laravel/framework/pull/40333)) +- Added method to MigrationsStarted/MigrationEnded events ([#40334](https://github.com/laravel/framework/pull/40334)) + +### Fixed +- Fixed failover mailer when used with Mailgun & SES mailers ([#40254](https://github.com/laravel/framework/pull/40254)) +- Fixed digits_between with fractions ([#40278](https://github.com/laravel/framework/pull/40278)) +- Fixed cursor pagination with HasManyThrough ([#40300](https://github.com/laravel/framework/pull/40300)) +- Fixed virtual attributes ([29a6692](https://github.com/laravel/framework/commit/29a6692fb0f0d14e5109ae5f02ed70065f10e966)) +- Fixed timezone option in `schedule:list` command ([#40304](https://github.com/laravel/framework/pull/40304)) +- Fixed Doctrine type mappings creating too many connections ([#40303](https://github.com/laravel/framework/pull/40303)) +- Fixed of resolving Blueprint class out of the container ([#40307](https://github.com/laravel/framework/pull/40307)) +- Handle type mismatch in the enum validation rule ([#40362](https://github.com/laravel/framework/pull/40362)) + +### Changed +- Automatically add event description when scheduling a command ([#40286](https://github.com/laravel/framework/pull/40286)) +- Update the Pluralizer Inflector instanciator ([#40336](https://github.com/laravel/framework/pull/40336)) + + +## [v8.78.1 (2022-01-05)](https://github.com/laravel/framework/compare/v8.78.0...v8.78.1) + +### Added +- Added pipeThrough collection method ([#40253](https://github.com/laravel/framework/pull/40253)) + +### Changed +- Run clearstatcache after deleting file and asserting Storage using exists/missing ([#40257](https://github.com/laravel/framework/pull/40257)) +- Avoid constructor call when fetching resource JSON options ([#40261](https://github.com/laravel/framework/pull/40261)) + + +## [v8.78.0 (2022-01-04)](https://github.com/laravel/framework/compare/v8.77.1...v8.78.0) + +### Added +- Added `schedule:clear-mutex` command ([#40135](https://github.com/laravel/framework/pull/40135)) +- Added ability to define extra default password rules ([#40137](https://github.com/laravel/framework/pull/40137)) +- Added a `mergeIfMissing` method to the Illuminate Http Request class ([#40116](https://github.com/laravel/framework/pull/40116)) +- Added `Illuminate/Support/MultipleInstanceManager` ([40913ac](https://github.com/laravel/framework/commit/40913ac8f8d07cca08c10ea7b4adc6c45b700b10)) +- Added `SimpleMessage::lines()` ([#40147](https://github.com/laravel/framework/pull/40147)) +- Added `Illuminate/Support/Testing/Fakes/BusFake::assertBatchCount()` ([#40217](https://github.com/laravel/framework/pull/40217)) +- Enable only-to-others functionality when using Ably broadcast driver ([#40234](https://github.com/laravel/framework/pull/40234)) +- Added ability to customize json options on JsonResource response ([#40208](https://github.com/laravel/framework/pull/40208)) +- Added `Illuminate/Support/Stringable::toHtmlString()` ([#40247](https://github.com/laravel/framework/pull/40247)) + +### Changed +- Improve support for custom Doctrine column types ([#40119](https://github.com/laravel/framework/pull/40119)) +- Remove an useless check in Console Application class ([#40145](https://github.com/laravel/framework/pull/40145)) +- Sort collections by key when first element of sort operation is string (even if callable) ([#40212](https://github.com/laravel/framework/pull/40212)) +- Use first host if multiple in `Illuminate/Database/Console/DbCommand::getConnection()` ([#40226](https://github.com/laravel/framework/pull/40226)) +- Improvement in the Reflector class ([#40241](https://github.com/laravel/framework/pull/40241)) + +### Fixed +- Clear recorded calls when calling Http::fake() ([#40194](https://github.com/laravel/framework/pull/40194)) +- Fixed attribute casting ([#40245](https://github.com/laravel/framework/pull/40245), [c0d9735](https://github.com/laravel/framework/commit/c0d97352c46ade8cc254b473580b2655ed474ffc)) + + +## [v8.77.1 (2021-12-21)](https://github.com/laravel/framework/compare/v8.77.0...v8.77.1) + +### Fixed +- Fixed prune command with default options ([#40127](https://github.com/laravel/framework/pull/40127)) + + +## [v8.77.0 (2021-12-21)](https://github.com/laravel/framework/compare/v8.76.2...v8.77.0) + +### Added +- Attribute Cast / Accessor Improvements ([#40022](https://github.com/laravel/framework/pull/40022)) +- Added `Illuminate/View/Factory::renderUnless()` ([#40077](https://github.com/laravel/framework/pull/40077)) +- Added datetime parsing to Request instance ([#39945](https://github.com/laravel/framework/pull/39945)) +- Make it possible to use prefixes on Predis per Connection ([#40083](https://github.com/laravel/framework/pull/40083)) +- Added rule to validate MAC address ([#40098](https://github.com/laravel/framework/pull/40098)) +- Added ability to define temporary URL macro for storage ([#40100](https://github.com/laravel/framework/pull/40100)) + +### Fixed +- Fixed possible out of memory error when deleting values by reference key from cache in Redis driver ([#40039](https://github.com/laravel/framework/pull/40039)) +- Added `Illuminate/Filesystem/FilesystemManager::setApplication()` ([#40058](https://github.com/laravel/framework/pull/40058)) +- Fixed arg passing in doesntContain ([739d847](https://github.com/laravel/framework/commit/739d8472eb2c97c4a8d8f86eb699c526e42f57fa)) +- Translate Enum rule message ([#40089](https://github.com/laravel/framework/pull/40089)) +- Fixed date validation ([#40088](https://github.com/laravel/framework/pull/40088)) +- Dont allow models and except together in PruneCommand.php ([f62fe66](https://github.com/laravel/framework/commit/f62fe66216ee11bc864e0877d4fd4be4655db4aa)) + +### Changed +- Passthru Eloquent\Query::explain function to Query\Builder:explain for the ability to use database-specific explain commands ([#40075](https://github.com/laravel/framework/pull/40075)) + + +## [v8.76.2 (2021-12-15)](https://github.com/laravel/framework/compare/v8.76.1...v8.76.2) + +### Added +- Added doesntContain method to Collection and LazyCollection ([#40044](https://github.com/laravel/framework/pull/40044), [3e3cbcf](https://github.com/laravel/framework/commit/3e3cbcf4cb4b8116f504a1e8363c7c958067b49a)) + +### Reverted +- Reverted ["Revert "[8.x] Remove redundant description & localize template"](https://github.com/laravel/framework/pull/39928) ([#40054](https://github.com/laravel/framework/pull/40054)) + + +## [v8.76.1 (2021-12-14)](https://github.com/laravel/framework/compare/v8.76.0...v8.76.1) + +### Reverted +- Reverted ["Fixed possible out of memory error when deleting values by reference key from cache in Redis driver"](https://github.com/laravel/framework/pull/39939) ([#40040](https://github.com/laravel/framework/pull/40040)) + + +## [v8.76.0 (2021-12-14)](https://github.com/laravel/framework/compare/v8.75.0...v8.76.0) + +### Added +- Added possibility to customize child model route binding resolution ([#39929](https://github.com/laravel/framework/pull/39929)) +- Added Illuminate/Http/Client/Response::reason() ([#39972](https://github.com/laravel/framework/pull/39972)) +- Added an afterRefreshingDatabase test method ([#39978](https://github.com/laravel/framework/pull/39978)) +- Added unauthorized() and forbidden() to Illuminate/Http/Client/Response ([#39979](https://github.com/laravel/framework/pull/39979)) +- Publish view-component.stub in stub:publish command ([#40007](https://github.com/laravel/framework/pull/40007)) +- Added invisible modifier for MySQL columns ([#40002](https://github.com/laravel/framework/pull/40002)) +- Added Str::substrReplace() and Str::of($string)->substrReplace() methods ([#39988](https://github.com/laravel/framework/pull/39988)) + +### Fixed +- Fixed parent call in view ([#39909](https://github.com/laravel/framework/pull/39909)) +- Fixed request dump and dd methods ([#39931](https://github.com/laravel/framework/pull/39931)) +- Fixed php 8.1 deprecation in ValidatesAttributes::checkDateTimeOrder ([#39937](https://github.com/laravel/framework/pull/39937)) +- Fixed withTrashed on routes check if SoftDeletes is used in Model ([#39958](https://github.com/laravel/framework/pull/39958)) +- Fixes model:prune --pretend command for models with SoftDeletes ([#39991](https://github.com/laravel/framework/pull/39991)) +- Fixed SoftDeletes force deletion sets "exists" property to false only when deletion succeeded ([#39987](https://github.com/laravel/framework/pull/39987)) +- Fixed possible out of memory error when deleting values by reference key from cache in Redis driver ([#39939](https://github.com/laravel/framework/pull/39939)) +- Fixed Password validation failure to allow errors after min rule ([#40030](https://github.com/laravel/framework/pull/40030)) + +### Changed +- Fail enum validation with pure enums ([#39926](https://github.com/laravel/framework/pull/39926)) +- Remove redundant description & localize template ([#39928](https://github.com/laravel/framework/pull/39928)) +- Fixes reporting deprecations when logger is not ready yet ([#39938](https://github.com/laravel/framework/pull/39938)) +- Replace escaped dot with place holder in dependent rules parameters ([#39935](https://github.com/laravel/framework/pull/39935)) +- passthru from property to underlying query object ([127334a](https://github.com/laravel/framework/commit/127334acbcb8bb012a4831c9fc17bc520c20e320)) + + +## [v8.75.0 (2021-12-07)](https://github.com/laravel/framework/compare/v8.74.0...v8.75.0) + +### Added +- Added `Illuminate/Support/Testing/Fakes/NotificationFake::assertSentTimes()` ([667cca8](https://github.com/laravel/framework/commit/667cca8db300f55cd8fccd575eaa46f5156b0408)) +- Added Conditionable trait to ComponentAttributeBag ([#39861](https://github.com/laravel/framework/pull/39861)) +- Added scheduler integration tests ([#39862](https://github.com/laravel/framework/pull/39862)) +- Added on-demand gate authorization ([#39789](https://github.com/laravel/framework/pull/39789)) +- Added countable interface to eloquent factory sequence ([#39907](https://github.com/laravel/framework/pull/39907), [1638472a](https://github.com/laravel/framework/commit/1638472a7a5ee02dc9e808bc203b733785ac1468), [#39915](https://github.com/laravel/framework/pull/39915)) +- Added Fulltext index for PostgreSQL ([#39875](https://github.com/laravel/framework/pull/39875)) +- Added method filterNulls() to Arr ([#39921](https://github.com/laravel/framework/pull/39921)) + +### Fixed +- Fixes AsEncrypted traits not respecting nullable columns ([#39848](https://github.com/laravel/framework/pull/39848), [4c32bf8](https://github.com/laravel/framework/commit/4c32bf815c93fe6fb6f78f1f9771e6baac379bd6)) +- Fixed http client factory class exists bugfix ([#39851](https://github.com/laravel/framework/pull/39851)) +- Fixed calls to Connection::rollBack() with incorrect case ([#39874](https://github.com/laravel/framework/pull/39874)) +- Fixed bug where columns would be guarded while filling Eloquent models during unit tests ([#39880](https://github.com/laravel/framework/pull/39880)) +- Fixed for dropping columns when using MSSQL as database ([#39905](https://github.com/laravel/framework/pull/39905)) + +### Changed +- Add proper paging offset when possible to sql server ([#39863](https://github.com/laravel/framework/pull/39863)) +- Correct pagination message in src/Illuminate/Pagination/resources/views/tailwind.blade.php ([#39894](https://github.com/laravel/framework/pull/39894)) + + +## [v8.74.0 (2021-11-30)](https://github.com/laravel/framework/compare/v8.73.2...v8.74.0) + +### Added +- Added optional `except` parameter to the PruneCommand ([#39749](https://github.com/laravel/framework/pull/39749), [be4afcc](https://github.com/laravel/framework/commit/be4afcc6c2a42402d4404263c6a5ca901d067dd2)) +- Added `Illuminate/Foundation/Application::hasDebugModeEnabled()` ([#39755](https://github.com/laravel/framework/pull/39755)) +- Added `Illuminate/Support/Facades/Event::fakeExcept()` and `Illuminate/Support/Facades/Event::fakeExceptFor()` ([#39752](https://github.com/laravel/framework/pull/39752)) +- Added aggregate method to Eloquent passthru ([#39772](https://github.com/laravel/framework/pull/39772)) +- Added `undot()` method to Arr helpers and Collections ([#39729](https://github.com/laravel/framework/pull/39729)) +- Added `reverse` method to `Str` ([#39816](https://github.com/laravel/framework/pull/39816)) +- Added possibility to customize type column in database notifications using databaseType method ([#39811](https://github.com/laravel/framework/pull/39811)) +- Added Fulltext Index ([#39821](https://github.com/laravel/framework/pull/39821)) + +### Fixed +- Fixed bus service provider when loaded outside of the framework ([#39740](https://github.com/laravel/framework/pull/39740)) +- Fixes logging deprecations when null driver do not exist ([#39809](https://github.com/laravel/framework/pull/39809)) + +### Changed +- Validate connection name before resolve queue connection ([#39751](https://github.com/laravel/framework/pull/39751)) +- Bump Symfony to 5.4 ([#39827](https://github.com/laravel/framework/pull/39827)) +- Optimize the execution time of the unique method ([#39822](https://github.com/laravel/framework/pull/39822)) + + +## [v8.73.2 (2021-11-23)](https://github.com/laravel/framework/compare/v8.73.1...v8.73.2) + +### Added +- Added `Illuminate/Foundation/Testing/Concerns/InteractsWithContainer::forgetMock()` ([#39713](https://github.com/laravel/framework/pull/39713)) +- Added custom pagination information in resource ([#39600](https://github.com/laravel/framework/pull/39600)) + + +## [v8.73.1 (2021-11-20)](https://github.com/laravel/framework/compare/v8.73.0...v8.73.1) + +### Revert +- Revert of [Use parents to resolve middleware priority in `SortedMiddleware`](https://github.com/laravel/framework/pull/39647) ([#39706](https://github.com/laravel/framework/pull/39706)) + + +## [v8.73.0 (2021-11-19)](https://github.com/laravel/framework/compare/v8.72.0...v8.73.0) + +### Added +- Added .phar to blocked PHP extensions in validator ([#39666](https://github.com/laravel/framework/pull/39666)) +- Allow a Closure to be passed as a ttl in Cache remember() method ([#39678](https://github.com/laravel/framework/pull/39678)) +- Added Prohibits validation rule to dependentRules property ([#39677](https://github.com/laravel/framework/pull/39677)) +- Implement lazyById in descending order ([#39646](https://github.com/laravel/framework/pull/39646)) + +### Fixed +- Fixed `Illuminate/Auth/Notifications/ResetPassword::toMail()` ([969f101](https://github.com/laravel/framework/commit/969f1014ec07efba803f887a33fde29e305c9cb1)) +- Fixed assertSoftDeleted & assertNotSoftDeleted ([#39673](https://github.com/laravel/framework/pull/39673)) + + +## [v8.72.0 (2021-11-17)](https://github.com/laravel/framework/compare/v8.71.0...v8.72.0) + +### Added +- Added extra method in PasswortReset for reset URL to match the structure of VerifyEmail ([#39652](https://github.com/laravel/framework/pull/39652)) +- Added support for countables to the `Illuminate/Support/Pluralizer::plural()` ([#39641](https://github.com/laravel/framework/pull/39641)) +- Allow users to specify options for migrate:fresh for DatabaseMigration trait ([#39637](https://github.com/laravel/framework/pull/39637)) + +### Fixed +- Casts $value to the int only when not null in `Illuminate/Database/Query/Builder::limit()` ([#39644](https://github.com/laravel/framework/pull/39644)) + +### Changed +- Use parents to resolve middleware priority in `SortedMiddleware` ([#39647](https://github.com/laravel/framework/pull/39647)) + + +## [v8.71.0 (2021-11-16)](https://github.com/laravel/framework/compare/v8.70.2...v8.71.0) + +### Added +- Added declined and declined_if validation rules ([#39579](https://github.com/laravel/framework/pull/39579)) +- Arrayable/collection support for Collection::splice() replacement param ([#39592](https://github.com/laravel/framework/pull/39592)) +- Introduce `@js()` directive ([#39522](https://github.com/laravel/framework/pull/39522)) +- Enum casts accept backed values ([#39608](https://github.com/laravel/framework/pull/39608)) +- Added a method to the Macroable trait that removes all configured macros. ([#39633](https://github.com/laravel/framework/pull/39633)) + +### Fixed +- Fixed auto-generated Markdown views ([#39565](https://github.com/laravel/framework/pull/39565)) +- DB command: Cope with missing driver parameters for mysql ([#39582](https://github.com/laravel/framework/pull/39582)) +- Fixed typo in Connection property name in `Illuminate/Database/Connection` ([#39590](https://github.com/laravel/framework/pull/39590)) +- Fixed: prevent re-casting of enum values ([#39597](https://github.com/laravel/framework/pull/39597)) +- Casts value to the int in `Illuminate/Database/Query/Builder::limit()` ([62273d2](https://github.com/laravel/framework/commit/62273d20dd13b7e35885436d7327be31e3f54b0e)) +- Fix $component not being reverted if component doesn't render ([#39595](https://github.com/laravel/framework/pull/39595)) + +### Changed +- `make:model --all` flag would auto-fire make:controller with --requests ([#39578](https://github.com/laravel/framework/pull/39578)) +- Allow assertion of multiple JSON validation errors. ([#39568](https://github.com/laravel/framework/pull/39568)) +- Ensure cache directory permissions ([#39591](https://github.com/laravel/framework/pull/39591)) +- Update placeholders for stubs ([#39527](https://github.com/laravel/framework/pull/39527)) + + +## [v8.70.2 (2021-11-10)](https://github.com/laravel/framework/compare/v8.70.1...v8.70.2) + +### Changed +- Use all in `Illuminate/Database/Query/Builder::cleanBindings()` ([74dcc02](https://github.com/laravel/framework/commit/74dcc024d5ac78a1c7c23a95c493736c2cd8d5a7)) + + +## [v8.70.1 (2021-11-09)](https://github.com/laravel/framework/compare/v8.70.0...v8.70.1) + +### Fixed +- Fixed problem with fallback in Router ([5fda5a3](https://github.com/laravel/framework/commit/5fda5a335bce1527e6796a91bb36ccb48d6807a8)) + + +## [v8.70.0 (2021-11-09)](https://github.com/laravel/framework/compare/v8.69.0...v8.70.0) + +### Added +- New flag `--requests` `-R` to `make:controller` and `make:model` Commands ([#39120](https://github.com/laravel/framework/pull/39120), [8fbfc9f](https://github.com/laravel/framework/commit/8fbfc9f16e48b202670e4b21588d8d752c3fbe90)) +- Allows Stringable objects as middleware. ([#39439](https://github.com/laravel/framework/pull/39439), [#39449](https://github.com/laravel/framework/pull/39449)) +- Introduce `Js` for encoding data to use in JavaScript ([#39389](https://github.com/laravel/framework/pull/39389), [#39460](https://github.com/laravel/framework/pull/39460), [bbf47d5](https://github.com/laravel/framework/commit/bbf47d5507c0ff018763170988284eeca6021fe8)) +- Added new lost connection error message for sqlsrv ([#39466](https://github.com/laravel/framework/pull/39466)) +- Allow can method to be chained onto route for quick authorization ([#39464](https://github.com/laravel/framework/pull/39464)) +- Publish `provider.stub` in stub:publish command ([#39491](https://github.com/laravel/framework/pull/39491)) +- Added `Illuminate/Support/NamespacedItemResolver::flushParsedKeys()` ([#39490](https://github.com/laravel/framework/pull/39490)) +- Accept enums for insert update and where ([#39492](https://github.com/laravel/framework/pull/39492)) +- Fifo support for queue name suffix ([#39497](https://github.com/laravel/framework/pull/39497), [12e47bb](https://github.com/laravel/framework/commit/12e47bb3dad10294268fa3167112b198fd0a2036)) + +### Changed +- Dont cache ondemand loggers ([5afa0f1](https://github.com/laravel/framework/commit/5afa0f1ee680e66360bc05f293eadca0d558f028), [bc50a9b](https://github.com/laravel/framework/commit/bc50a9b10097e66b59f0dfcabc6e100b8fedc760)) +- Enforce implicit Route Model scoping ([#39440](https://github.com/laravel/framework/pull/39440)) +- Ensure event mutex is always removed ([#39498](https://github.com/laravel/framework/pull/39498)) +- Added missing "flags" to redis zadd options list... ([#39538](https://github.com/laravel/framework/pull/39538)) + + +## [v8.69.0 (2021-11-02)](https://github.com/laravel/framework/compare/v8.68.1...v8.69.0) + +### Added +- Improve content negotiation for exception handling ([#39385](https://github.com/laravel/framework/pull/39385)) +- Added support for SKIP LOCKED to MariaDB ([#39396](https://github.com/laravel/framework/pull/39396)) +- Custom cast string into Stringable ([#39410](https://github.com/laravel/framework/pull/39410)) +- Added `Illuminate/Support/Str::mask()` ([#39393](https://github.com/laravel/framework/pull/39393)) +- Allow model attributes to be casted to/from an Enum ([#39315](https://github.com/laravel/framework/pull/39315)) +- Added an Enum validation rule ([#39437](https://github.com/laravel/framework/pull/39437)) +- Auth: Allows to use a callback in credentials array ([#39420](https://github.com/laravel/framework/pull/39420)) +- Added success and failure command assertions ([#39435](https://github.com/laravel/framework/pull/39435)) + +### Fixed +- Fixed CURRENT_TIMESTAMP as default when changing column ([#39377](https://github.com/laravel/framework/pull/39377)) +- Make accept header comparison case-insensitive ([#39413](https://github.com/laravel/framework/pull/39413)) +- Fixed regression with capitalizing translation params ([#39424](https://github.com/laravel/framework/pull/39424)) + +### Changed +- Added bound check to env resolving in `Illuminate/Foundation/Application::runningUnitTests()` ([#39434](https://github.com/laravel/framework/pull/39434)) + + +## [v8.68.1 (2021-10-27)](https://github.com/laravel/framework/compare/v8.68.0...v8.68.1) + +### Reverted +- Reverted ["Added support for MariaDB to skip locked rows with the database queue driver"](https://github.com/laravel/framework/pull/39311) ([#39386](https://github.com/laravel/framework/pull/39386)) + +### Fixed +- Fixed code to address different connection strings for MariaDB in the database queue driver ([#39374](https://github.com/laravel/framework/pull/39374)) +- Fixed rate limiting unicode issue ([#39375](https://github.com/laravel/framework/pull/39375)) +- Fixed bug with closure formatting in `Illuminate/Testing/Fluent/Concerns/Matching::whereContains()` ([37217d5](https://github.com/laravel/framework/commit/37217d56ca38c407395bb98ef2532cafd86efa30)) + +### Refactoring +- Change whereStartsWith, DocBlock to reflect that array is supported ([#39370](https://github.com/laravel/framework/pull/39370)) + + +## [v8.68.0 (2021-10-26)](https://github.com/laravel/framework/compare/v8.67.0...v8.68.0) + +### Added +- Added ThrottleRequestsWithRedis to $middlewarePriority ([#39316](https://github.com/laravel/framework/pull/39316)) +- Added `Illuminate/Database/Schema/ForeignKeyDefinition::restrictOnUpdate()` ([#39350](https://github.com/laravel/framework/pull/39350)) +- Added `ext-bcmath` as an extension suggestion to the composer.json ([#39360](https://github.com/laravel/framework/pull/39360)) +- Added `TestResponse::dd` ([#39359](https://github.com/laravel/framework/pull/39359)) + +### Fixed +- TaggedCache flush should also remove tags from cache ([#39299](https://github.com/laravel/framework/pull/39299)) +- Fixed model serialization on anonymous components ([#39319](https://github.com/laravel/framework/pull/39319)) + +### Changed +- Changed to Guess database factory model by default ([#39310](https://github.com/laravel/framework/pull/39310)) + + +## [v8.67.0 (2021-10-22)](https://github.com/laravel/framework/compare/v8.66.0...v8.67.0) + +### Added +- Added support for MariaDB to skip locked rows with the database queue driver ([#39311](https://github.com/laravel/framework/pull/39311)) +- Added PHP 8.1 Support ([#39034](https://github.com/laravel/framework/pull/39034)) + +### Fixed +- Fixed translation bug ([#39298](https://github.com/laravel/framework/pull/39298)) +- Fixed Illuminate/Database/DetectsConcurrencyErrors::causedByConcurrencyError() when code is intager ([#39280](https://github.com/laravel/framework/pull/39280)) +- Fixed unique bug in Bus ([#39302](https://github.com/laravel/framework/pull/39302)) + +### Changed +- Only select related columns by default in CanBeOneOfMany::ofMany ([#39307](https://github.com/laravel/framework/pull/39307)) + + +## [v8.66.0 (2021-10-21)](https://github.com/laravel/framework/compare/v8.65.0...v8.66.0) + +### Added +- Added withoutDeprecationHandling to testing ([#39261](https://github.com/laravel/framework/pull/39261)) +- Added method for on-demand log creation ([#39273](https://github.com/laravel/framework/pull/39273)) +- Added dateTime to columns that don't need character options ([#39269](https://github.com/laravel/framework/pull/39269)) +- Added `AssertableJson::hasAny` ([#39265](https://github.com/laravel/framework/pull/39265)) +- Added `Arr::isList()` method ([#39277](https://github.com/laravel/framework/pull/39277)) +- Apply withoutGlobalScope in CanBeOneOfMany subqueries ([#39295](https://github.com/laravel/framework/pull/39295)) +- Added `Illuminate/Support/Testing/Fakes/BusFake::assertNothingDispatched()` ([#39286](https://github.com/laravel/framework/pull/39286)) + +### Reverted +- Revert ["[8.x] Add gate policy callback"](https://github.com/laravel/framework/pull/39185) ([#39290](https://github.com/laravel/framework/pull/39290)) + + +## [v8.65.0 (2021-10-19)](https://github.com/laravel/framework/compare/v8.64.0...v8.65.0) + +### Added +- Allow queueing application and service provider callbacks while callbacks are already being processed ([#39175](https://github.com/laravel/framework/pull/39175), [63dab48](https://github.com/laravel/framework/commit/63dab486a990e26500b1a6520b1493192d6c5104)) +- Added ability to validate one of multiple date formats ([#39170](https://github.com/laravel/framework/pull/39170)) +- Re-add update from support for PostgreSQL ([#39151](https://github.com/laravel/framework/pull/39151)) +- Added `Illuminate/Collections/Traits/EnumeratesValues::reduceSpread()` ([a01e9ed](https://github.com/laravel/framework/commit/a01e9edfadb140559d1bbf9999dda49148bfa5f7)) +- Added `Illuminate/Testing/TestResponse::assertRedirectContains()` ([#39233](https://github.com/laravel/framework/pull/39233), [ff340a6](https://github.com/laravel/framework/commit/ff340a6809d07b349aa227c2e4caf3a3ad8f47d5)) +- Added gate policy callback ([#39185](https://github.com/laravel/framework/pull/39185)) +- Allow Remember Me cookie time to be overriden ([#39186](https://github.com/laravel/framework/pull/39186)) +- Adds `--test` and `--pest` options to various `make` commands ([#38997](https://github.com/laravel/framework/pull/38997)) +- Added new lost connection message to DetectsLostConnections for Vapor ([#39209](https://github.com/laravel/framework/pull/39209)) +- Added `Illuminate/Support/Testing/Fakes/NotificationFake::assertSentOnDemand()` ([#39203](https://github.com/laravel/framework/pull/39203)) +- Added Subset in request's collect ([#39191](https://github.com/laravel/framework/pull/39191)) +- Added Conditional trait to Eloquent Factory ([#39228](https://github.com/laravel/framework/pull/39228)) +- Added a way to skip count check but check $callback at the same time for AssertableJson->has() ([#39224](https://github.com/laravel/framework/pull/39224)) +- Added `Illuminate/Support/Str::headline()` ([#39174](https://github.com/laravel/framework/pull/39174)) + +### Deprecated +- Deprecate `reduceMany` in favor of `reduceSpread` in `Illuminate/Collections/Traits/EnumeratesValues` ([#39201](https://github.com/laravel/framework/pull/39201)) + +### Fixed +- Fixed HasOneOfMany with callback issue ([#39187](https://github.com/laravel/framework/pull/39187)) + +### Changed +- Logs deprecations instead of treating them as exceptions ([#39219](https://github.com/laravel/framework/pull/39219)) + + +## [v8.64.0 (2021-10-12)](https://github.com/laravel/framework/compare/v8.63.0...v8.64.0) + +### Added +- Added reduceMany to Collections ([#39078](https://github.com/laravel/framework/pull/39078)) +- Added `Illuminate/Support/Stringable::stripTags()` ([#39098](https://github.com/laravel/framework/pull/39098)) +- Added `Illuminate/Console/OutputStyle::getOutput()` ([#39099](https://github.com/laravel/framework/pull/39099)) +- Added `lang_path` helper function ([#39103](https://github.com/laravel/framework/pull/39103)) +- Added @aware blade directive ([#39100](https://github.com/laravel/framework/pull/39100)) +- New JobRetrying event dispatched ([#39097](https://github.com/laravel/framework/pull/39097)) +- Added throwIf method in Client Response ([#39148](https://github.com/laravel/framework/pull/39148)) +- Added Illuminate/Collections/Collection::hasAny() ([#39155](https://github.com/laravel/framework/pull/39155)) + +### Fixed +- Fixed route groups with no prefix on PHP 8.1 ([#39115](https://github.com/laravel/framework/pull/39115)) +- Fixed code locating Bearer token in InteractsWithInput ([#39150](https://github.com/laravel/framework/pull/39150)) + +### Changed +- Refactoring `Illuminate/Log/LogManager::prepareHandler()` ([#39093](https://github.com/laravel/framework/pull/39093)) +- Flush component state when done rendering in View ([04fc7c2](https://github.com/laravel/framework/commit/04fc7c2f87372511b0f77e539bc0e2e3357ec200)) +- Ignore tablespaces in dump ([#39126](https://github.com/laravel/framework/pull/39126)) +- Update SchemaState Process to remove timeout ([#39139](https://github.com/laravel/framework/pull/39139)) + + +## [v8.63.0 (2021-10-05)](https://github.com/laravel/framework/compare/v8.62.0...v8.63.0) + +### Added +- Added new lost connection message to DetectsLostConnections ([#39028](https://github.com/laravel/framework/pull/39028)) +- Added whereBelongsTo() Eloquent builder method ([#38927](https://github.com/laravel/framework/pull/38927)) +- Added Illuminate/Foundation/Testing/Wormhole::minute() ([#39050](https://github.com/laravel/framework/pull/39050)) + +### Fixed +- Fixed castable value object not serialized correctly ([#39020](https://github.com/laravel/framework/pull/39020)) +- Fixed casting to string on PHP 8.1 ([#39033](https://github.com/laravel/framework/pull/39033)) +- Mail empty address handling ([#39035](https://github.com/laravel/framework/pull/39035)) +- Fixed NotPwnedVerifier failures ([#39038](https://github.com/laravel/framework/pull/39038)) +- Fixed LazyCollection#unique() double enumeration ([#39041](https://github.com/laravel/framework/pull/39041)) + +### Changed +- HTTP client: only allow a single User-Agent header ([#39085](https://github.com/laravel/framework/pull/39085)) + + +## [v8.62.0 (2021-09-28)](https://github.com/laravel/framework/compare/v8.61.0...v8.62.0) + +### Added +- Added singular syntactic sugar to wormhole ([#38815](https://github.com/laravel/framework/pull/38815)) +- Added a few PHP 8.1 related changes ([#38404](https://github.com/laravel/framework/pull/38404), [#38961](https://github.com/laravel/framework/pull/38961)) +- Dispatch events when maintenance mode is enabled and disabled ([#38826](https://github.com/laravel/framework/pull/38826)) +- Added assertNotSoftDeleted Method ([#38886](https://github.com/laravel/framework/pull/38886)) +- Adds new RefreshDatabaseLazily testing trait ([#38861](https://github.com/laravel/framework/pull/38861)) +- Added --pretend option for model:prune command ([#38945](https://github.com/laravel/framework/pull/38945)) +- Make PendingMail Conditionable ([#38942](https://github.com/laravel/framework/pull/38942)) +- Adds --pest option when using the make:test artisan command ([#38966](https://github.com/laravel/framework/pull/38966)) + +### Reverted +- Reverted ["Added posibility compare custom date/immutable_date using date comparison"](https://github.com/laravel/framework/pull/38720) ([#38993](https://github.com/laravel/framework/pull/38993)) + +### Fixed +- Fix getDirty method when using AsArrayObject / AsCollection ([#38869](https://github.com/laravel/framework/pull/38869)) +- Fix sometimes conditions that add rules for sibling values within an array of data ([#38899](https://github.com/laravel/framework/pull/38899)) +- Fixed Illuminate/Validation/Rules/Password::passes() ([#38962](https://github.com/laravel/framework/pull/38962)) +- Fixed for custom date castable and database value formatting ([#38994](https://github.com/laravel/framework/pull/38994)) + +### Changed +- Make mailable assertions fluent ([#38850](https://github.com/laravel/framework/pull/38850)) +- Allow request input to be retrieved as a collection ([#38832](https://github.com/laravel/framework/pull/38832)) +- Allow index.blade.php views for anonymous components ([#38847](https://github.com/laravel/framework/pull/38847)) +- Changed *ofMany to decide relationship name when it is null ([#38889](https://github.com/laravel/framework/pull/38889)) +- Ignore trailing delimiter in cache.headers options string ([#38910](https://github.com/laravel/framework/pull/38910)) +- Only look for files ending with .php in model:prune ([#38975](https://github.com/laravel/framework/pull/38975)) +- Notification assertions respect shouldSend method on notification ([#38979](https://github.com/laravel/framework/pull/38979)) +- Convert middleware to array when outputting as JSON in /RouteListCommand ([#38953](https://github.com/laravel/framework/pull/38953)) + + +## [v8.61.0 (2021-09-13)](https://github.com/laravel/framework/compare/v8.60.0...v8.61.0) + +### Added +- Added posibility compare custom date/immutable_date using date comparison ([#38720](https://github.com/laravel/framework/pull/38720)) +- Added policy option to make:model ([#38725](https://github.com/laravel/framework/pull/38725) +- Allow tests to utilise the null logger ([#38785](https://github.com/laravel/framework/pull/38785)) +- Added deleteOrFail to Model ([#38784](https://github.com/laravel/framework/pull/38784)) +- Added assertExists testing method ([#38766](https://github.com/laravel/framework/pull/38766)) +- Added forwardDecoratedCallTo to Illuminate/Database/Eloquent/Relations/Relation ([#38800](https://github.com/laravel/framework/pull/38800)) +- Adding support for using a different Redis DB in a Sentinel setup ([#38764](https://github.com/laravel/framework/pull/38764)) + +### Changed +- Return on null in `Illuminate/Queue/Queue::getJobBackoff()` ([27bcf13](https://github.com/laravel/framework/commit/27bcf13ce0fb64d7677e1376bf6fde0fc08810a2)) +- Provide psr/simple-cache-implementation ([#38767](https://github.com/laravel/framework/pull/38767)) +- Use lowercase for hmac hash algorithm ([#38787](https://github.com/laravel/framework/pull/38787)) + + +## [v8.60.0 (2021-09-08)](https://github.com/laravel/framework/compare/v8.59.0...v8.60.0) + +### Added +- Added the `valueOfFail()` Eloquent builder method ([#38707](https://github.com/laravel/framework/pull/38707)) + +### Reverted +- Reverted ["Added the password reset URL to the toMailCallback"](https://github.com/laravel/framework/pull/38552)) ([#38711](https://github.com/laravel/framework/pull/38711)) + + +## [v8.59.0 (2021-09-07)](https://github.com/laravel/framework/compare/v8.58.0...v8.59.0) + +### Added +- Allow quiet creation ([e9cd94c](https://github.com/laravel/framework/commit/e9cd94c89f59c833c13d04f32f1e31db419a4c0c)) +- Added merge() function to ValidatedInput ([#38640](https://github.com/laravel/framework/pull/38640)) +- Added support for disallowing class morphs ([#38656](https://github.com/laravel/framework/pull/38656)) +- Added AssertableJson::each() method ([#38684](https://github.com/laravel/framework/pull/38684)) +- Added Eloquent builder whereMorphedTo method to streamline finding models morphed to another model ([#38668](https://github.com/laravel/framework/pull/38668)) + +### Fixed +- Silence Validator Date Parse Warnings ([#38652](https://github.com/laravel/framework/pull/38652)) + +### Changed +- Remove mapWithKeys from HTTP Client headers() methods ([#38643](https://github.com/laravel/framework/pull/38643)) +- Return a new or existing guzzle client based on context in `Illuminate/Http/Client/PendingRequest::buildClient()` ([#38642](https://github.com/laravel/framework/pull/38642)) +- Show a pretty diff for assertExactJson() ([#38655](https://github.com/laravel/framework/pull/38655)) +- Lowercase cipher name in the Encrypter supported method ([#38693](https://github.com/laravel/framework/pull/38693)) + + +## [v8.58.0 (2021-08-31)](https://github.com/laravel/framework/compare/v8.57.0...v8.58.0) + +### Added +- Added updateOrFail method to Model ([#38592](https://github.com/laravel/framework/pull/38592)) +- Make mail stubs more configurable ([#38596](https://github.com/laravel/framework/pull/38596)) +- Added prohibits validation ([#38612](https://github.com/laravel/framework/pull/38612)) + +### Changed +- Use lowercase OpenSSL cipher names ([#38594](https://github.com/laravel/framework/pull/38594), [#38600](https://github.com/laravel/framework/pull/38600)) + + +## [v8.57.0 (2021-08-27)](https://github.com/laravel/framework/compare/v8.56.0...v8.57.0) + +### Added +- Added exclude validation rule ([#38537](https://github.com/laravel/framework/pull/38537)) +- Allow passing when callback to Http client retry method ([#38531](https://github.com/laravel/framework/pull/38531)) +- Added `Illuminate/Testing/TestResponse::assertUnprocessable()` ([#38553](https://github.com/laravel/framework/pull/38553)) +- Added the password reset URL to the toMailCallback ([#38552](https://github.com/laravel/framework/pull/38552)) +- Added a simple where helper for querying relations ([#38499](https://github.com/laravel/framework/pull/38499)) +- Allow sync broadcast via method ([#38557](https://github.com/laravel/framework/pull/38557)) +- Make $validator->sometimes() item aware to be able to work with nested arrays ([#38443](https://github.com/laravel/framework/pull/38443)) + +### Fixed +- Fixed Blade component falsy slots ([#38546](https://github.com/laravel/framework/pull/38546)) +- Keep backward compatibility with custom ciphers in `Illuminate/Encryption/Encrypter::generateKey()` ([#38556](https://github.com/laravel/framework/pull/38556)) +- Fixed bug discarding input fields with empty validation rules ([#38563](https://github.com/laravel/framework/pull/38563)) + +### Changed +- Don't iterate over all collection in Collection::firstOrFail ([#38536](https://github.com/laravel/framework/pull/38536)) +- Error out when detecting incompatible DBAL version ([#38543](https://github.com/laravel/framework/pull/38543)) + + +## [v8.56.0 (2021-08-24)](https://github.com/laravel/framework/compare/v8.55.0...v8.56.0) + +### Added +- Added firstOrFail to Illuminate\Support\Collections and Illuminate\Support\LazyCollections ([#38420](https://github.com/laravel/framework/pull/38420)) +- Support route caching with trashed bindings ([c3ec2f2](https://github.com/laravel/framework/commit/c3ec2f2d2ad15f2e35cebaa6fcf242ce22af2f8a)) +- Allow only keys directly on safe in FormRequest ([5e4ded8](https://github.com/laravel/framework/commit/5e4ded83bacc64e5604b6f71496734071c53b221)) +- Added default rules in conditional rules ([#38450](https://github.com/laravel/framework/pull/38450)) +- Added fullUrlWithoutQuery method to Request ([#38482](https://github.com/laravel/framework/pull/38482)) +- Added --implicit (and -i) option to make:rule ([#38480](https://github.com/laravel/framework/pull/38480)) +- Added colon port support in serve command host option ([#38522](https://github.com/laravel/framework/pull/38522)) + +### Changed +- Testing: Access component properties from the return value of $this->component() ([#38396](https://github.com/laravel/framework/pull/38396), [42a71fd](https://github.com/laravel/framework/commit/42a71fded8b552321f1a1b962cb17e273c7cdf24)) +- Update InteractsWithInput::bearerToken() ([#38426](https://github.com/laravel/framework/pull/38426)) +- Minor improvements to validation assertions API ([#38422](https://github.com/laravel/framework/pull/38422)) +- Blade component slot attributes ([#38372](https://github.com/laravel/framework/pull/38372)) +- Convenient methods for rate limiting ([2f93c49](https://github.com/laravel/framework/commit/2f93c4949b60e9b13a3a2d9e5ebb096bd1ae98a9)) +- Run event:clear on optimize:clear ([a61b24c2](https://github.com/laravel/framework/commit/a61b24c2d266aee6000f9e768df8c1a7be8fd9d1)) +- Remove unnecessary double MAC for AEAD ciphers ([#38475](https://github.com/laravel/framework/pull/38475)) +- Adds Response authorization to Form Requests ([#38489](https://github.com/laravel/framework/pull/38489)) +- Make TestResponse::getCookie public so it can be directly used in tests ([#38524](https://github.com/laravel/framework/pull/38524)) + + +## [v8.55.0 (2021-08-17)](https://github.com/laravel/framework/compare/v8.54.0...v8.55.0) + +### Added +- Added stringable support for isUuid ([#38330](https://github.com/laravel/framework/pull/38330)) +- Allow for closure reflection on all MailFake assertions ([#38328](https://github.com/laravel/framework/pull/38328)) +- Added `Illuminate/Support/Testing/Fakes/MailFake::assertNothingOutgoing()` ([363af47](https://github.com/laravel/framework/commit/363af4793bfac97f2d846f5fa6bb985ce6a5642e)) +- Added `Illuminate/Support/Testing/Fakes/MailFake::assertNotOutgoing()` ([a3658c9](https://github.com/laravel/framework/commit/a3658c93695b79b3f9a8fc72c04c6d928dcc51a9)) +- Added Support withTrashed on routes ([#38348](https://github.com/laravel/framework/pull/38348)) +- Added Failover Swift Transport driver ([#38344](https://github.com/laravel/framework/pull/38344)) +- Added Conditional rules ([#38361](https://github.com/laravel/framework/pull/38361)) +- Added assertRedirectToSignedRoute() method for testing responses ([#38349](https://github.com/laravel/framework/pull/38349)) +- Added Validated subsets ([#38366](https://github.com/laravel/framework/pull/38366)) +- Share handler instead of client between requests in pool to ensure ResponseReceived events are dispatched in async HTTP Request ([#38380](https://github.com/laravel/framework/pull/38380)) +- Support union types on event discovery ([#38383](https://github.com/laravel/framework/pull/38383)) +- Added Assert invalid in testResponse ([#38384](https://github.com/laravel/framework/pull/38384)) +- Add qualifyColumns method to Model class ([#38403](https://github.com/laravel/framework/pull/38403)) +- Added ability to throw a custom validation exception ([#38406](https://github.com/laravel/framework/pull/38406)) +- Support shorter subscription syntax ([#38408](https://github.com/laravel/framework/pull/38408)) + +### Fixed +- Handle exceptions in batch callbacks ([#38327](https://github.com/laravel/framework/pull/38327)) +- Bump AWS PHP SDK ([#38297](https://github.com/laravel/framework/pull/38297)) +- Fixed firstOrCreate and firstOrNew should merge attributes correctly ([#38346](https://github.com/laravel/framework/pull/38346)) +- Check for incomplete class to prevent unexpected error when class cannot be loaded in retry command ([#38379](https://github.com/laravel/framework/pull/38379)) + +### Changed +- Update the ParallelRunner to allow for a custom Runner to be resolved ([#38374](https://github.com/laravel/framework/pull/38374)) +- Use Fluent instead of array on Rule::when() ([#38397](https://github.com/laravel/framework/pull/38397)) + + +## [v8.54.0 (2021-08-10)](https://github.com/laravel/framework/compare/v8.53.1...v8.54.0) + +### Added +- Added support for GCM encryption ([#38190](https://github.com/laravel/framework/pull/38190), [827bc1d](https://github.com/laravel/framework/commit/827bc1de8b400fd7cc3edd3391124dc9003f1ddc)) +- Added exception as parameter to the missing() callbacks in `Illuminate/Routing/Middleware/SubstituteBindings.php` ([#38289](https://github.com/laravel/framework/pull/38289)) +- Implement TrustProxies middleware ([#38295](https://github.com/laravel/framework/pull/38295)) +- Added bitwise not operator to `Illuminate/Database/Query/Builder.php` ([#38316](https://github.com/laravel/framework/pull/38316)) +- Adds attempt method to RateLimiter ([#38313](https://github.com/laravel/framework/pull/38313)) +- Added withoutTrashed on Exists rule ([#38314](https://github.com/laravel/framework/pull/38314)) + +### Changed +- Wraps column name inside subQuery of hasOneOfMany-relationship ([#38263](https://github.com/laravel/framework/pull/38263)) +- Change Visibility of the Markdown property in Mailable ([#38320](https://github.com/laravel/framework/pull/38320)) +- Swap multiple logical OR for in_array when checking date casting ([#38307](https://github.com/laravel/framework/pull/38307)) + +### Fixed +- Fixed out of bounds shift and pop behavior in Collection ([bd89575](https://github.com/laravel/framework/commit/bd89575218afd14cbc12fde4be56607e40aeded9)) +- Fixed schedule timezone when using CarbonImmutable ([#38297](https://github.com/laravel/framework/pull/38297)) +- Fixed isDateCastable for the new immutable_date and immutable_datetime casts ([#38294](https://github.com/laravel/framework/pull/38294)) +- Fixed Factory hasMany method ([#38319](https://github.com/laravel/framework/pull/38319)) + + +## [v8.53.1 (2021-08-05)](https://github.com/laravel/framework/compare/v8.53.0...v8.53.1) + +### Added +- Added placeholders replace for accepted_if validation message ([#38240](https://github.com/laravel/framework/pull/38240)) + +### Fixed +- Use type hints in cast.stub to match interface ([#38234](https://github.com/laravel/framework/pull/38234)) +- Some PHP 8.1 fixes ([#38245](https://github.com/laravel/framework/pull/38245)) +- Fixed aliasing with cursor pagination ([#38251](https://github.com/laravel/framework/pull/38251)) +- Fixed signed routes ([#38249](https://github.com/laravel/framework/pull/38249)) + + +## [v8.53.0 (2021-08-03)](https://github.com/laravel/framework/compare/v8.52.0...v8.53.0) + +### Added +- Added cache_locks table to cache stub ([#38152](https://github.com/laravel/framework/pull/38152)) +- Added queue:monitor command ([#38168](https://github.com/laravel/framework/pull/38168)) +- Added twiceDailyAt schedule frequency ([#38174](https://github.com/laravel/framework/pull/38174)) +- Added immutable date and datetime casting ([#38199](https://github.com/laravel/framework/pull/38199)) +- Allow the php web server to run multiple workers ([#38208](https://github.com/laravel/framework/pull/38208)) +- Added accepted_if validation rule ([#38210](https://github.com/laravel/framework/pull/38210)) + +### Fixed +- Fixed signed routes with expires parameter ([#38111](https://github.com/laravel/framework/pull/38111), [732c0e0](https://github.com/laravel/framework/commit/732c0e0f64b222e7fc7daef6553f8e99007bb32c)) +- Remove call to deleted method in `Illuminate/Testing/TestResponse::statusMessageWithException()` ([cde3662](https://github.com/laravel/framework/commit/cde36626376e014390713ab03a01eb4dfe6488ce)) +- Fixed previous column for cursor pagination ([#38203](https://github.com/laravel/framework/pull/38203)) + +### Changed +- Prevent assertStatus() invalid JSON exception for valid JSON response content ([#38192](https://github.com/laravel/framework/pull/38192)) +- Bump AWS SDK to `^3.186.4` ([#38216](https://github.com/laravel/framework/pull/38216)) +- Implement `ReturnTypeWillChange` for some place ([#38221](https://github.com/laravel/framework/pull/38221), [#38212](https://github.com/laravel/framework/pull/38212), [#38226](https://github.com/laravel/framework/pull/38226)) +- Use actual countable interface on MessageBag ([#38227](https://github.com/laravel/framework/pull/38227)) + +### Refactoring +- Remove hardcoded Carbon reference from scheduler event ([#38063](https://github.com/laravel/framework/pull/38063)) + + +## [v8.52.0 (2021-07-27)](https://github.com/laravel/framework/compare/v8.51.0...v8.52.0) + +### Added +- Allow shift() and pop() to take multiple items from a collection ([#38093](https://github.com/laravel/framework/pull/38093)) +- Added hook to configure broadcastable model event ([5ca5768](https://github.com/laravel/framework/commit/5ca5768db439887217c86031ff7dd3bdf56cc466), [aca6f90](https://github.com/laravel/framework/commit/aca6f90b7177361b8d1f4ca6eecea78403f32583)) +- Support a proxy URL for mix hot ([#38118](https://github.com/laravel/framework/pull/38118)) +- Added `Illuminate/Validation/Rules/Unique::withoutTrashed()` ([#38124](https://github.com/laravel/framework/pull/38124)) +- Support job middleware on queued listeners ([#38128](https://github.com/laravel/framework/pull/38128)) +- Model Broadcasting - Adding broadcastWith() and broadcastAs() support ([#38137](https://github.com/laravel/framework/pull/38137)) +- Allow parallel testing without database creation ([#38143](https://github.com/laravel/framework/pull/38143)) + +### Fixed +- Fixed display of validation errors occurred when asserting status ([#38088](https://github.com/laravel/framework/pull/38088)) +- Developer friendly message if no Prunable Models found ([#38108](https://github.com/laravel/framework/pull/38108)) +- Fix running schedule:test on CallbackEvent ([#38146](https://github.com/laravel/framework/pull/38146)) + +### Changed +- BelongsToMany->sync() will support touching for pivots when the result contains detached items ([#38085](https://github.com/laravel/framework/pull/38085)) +- Ability to specify the broadcaster to use when broadcasting an event ([#38086](https://github.com/laravel/framework/pull/38086)) +- Password Validator should inherit custom error message and attribute ([#38114](https://github.com/laravel/framework/pull/38114)) + + +## [v8.51.0 (2021-07-20)](https://github.com/laravel/framework/compare/v8.50.0...v8.51.0) + +### Added +- Allow dynamically customizing connection for queued event listener ([#38005](https://github.com/laravel/framework/pull/38005), [ebc3ce4](https://github.com/laravel/framework/commit/ebc3ce49fb99e85fc2b5695fd9d88b95429bc5a0)) +- Added `@class` Blade directive ([#38016](https://github.com/laravel/framework/pull/38016)) +- Accept closure for retry() sleep ([#38035](https://github.com/laravel/framework/pull/38035)) +- The controller can directly return the stdClass object ([#38033](https://github.com/laravel/framework/pull/38033)) +- Make FilesystemAdapter macroable ([#38030](https://github.com/laravel/framework/pull/38030)) +- Track exceptions and display them on failed status checks for dx ([#38025](https://github.com/laravel/framework/pull/38025)) +- Display unexpected validation errors when asserting status ([#38046](https://github.com/laravel/framework/pull/38046)) +- Ability to return the default value of a request whenHas and whenFilled methods ([#38060](https://github.com/laravel/framework/pull/38060)) +- Added `Filesystem::replaceInFile()` method ([#38069](https://github.com/laravel/framework/pull/38069)) + +### Fixed +- Fixed passing cursor to pagination methods ([#37996](https://github.com/laravel/framework/pull/37996)) +- Fixed issue with cursor pagination and Json resources ([#38026](https://github.com/laravel/framework/pull/38026)) +- ErrorException: Undefined array key "exception" ([#38059](https://github.com/laravel/framework/pull/38059)) +- Fixed unvalidated array keys without implicit attributes ([#38052](https://github.com/laravel/framework/pull/38052)) + +### Changed +- Passthrough excluded uri's in maintenance mode ([#38041](https://github.com/laravel/framework/pull/38041)) +- Allow for named arguments via dispatchable trait ([#38066](https://github.com/laravel/framework/pull/38066)) + + +## [v8.50.0 (2021-07-13)](https://github.com/laravel/framework/compare/v8.49.2...v8.50.0) + +### Added +- Added ability to cancel notifications immediately prior to sending ([#37930](https://github.com/laravel/framework/pull/37930)) +- Added the possibility of having "Prunable" models ([#37889](https://github.com/laravel/framework/pull/37889)) +- Added support for both CommonMark 1.x and 2.x ([#37954](https://github.com/laravel/framework/pull/37954)) +- Added `Illuminate/Validation/Factory::excludeUnvalidatedArrayKeys()` ([#37943](https://github.com/laravel/framework/pull/37943)) + +### Fixed +- Fixed `Illuminate/Bus/PendingBatch::add()` ([108385b](https://github.com/laravel/framework/commit/108385b4f98cacfc1ef1d6e323f57b1c2df3180f)) +- Cursor pagination fixes ([#37915](https://github.com/laravel/framework/pull/37915)) + +### Changed +- Mixed orders in cursor paginate ([#37762](https://github.com/laravel/framework/pull/37762)) +- Clear config after dumping auto-loaded files ([#37985](https://github.com/laravel/framework/pull/37985)) + + +## [v8.49.2 (2021-07-07)](https://github.com/laravel/framework/compare/v8.49.1...v8.49.2) + +### Added +- Adds ResponseReceived events to async requests of HTTP Client ([#37917](https://github.com/laravel/framework/pull/37917)) + +### Fixed +- Fixed edge case causing a BadMethodCallExceptions to be thrown when using loadMissing() ([#37871](https://github.com/laravel/framework/pull/37871)) + + +## [v8.49.1 (2021-07-02)](https://github.com/laravel/framework/compare/v8.49.0...v8.49.1) + +### Reverted +- Reverted [Bind mock instances as singletons so they are not overwritten](https://github.com/laravel/framework/pull/37746) ([#37892](https://github.com/laravel/framework/pull/37892)) + +### Fixed +- Fixed undefined array key in SqlServerGrammar when using orderByRaw ([#37859](https://github.com/laravel/framework/pull/37859)) +- Fixed facade isMock to recognise LegacyMockInterface ([#37882](https://github.com/laravel/framework/pull/37882)) + +### Changed +- Reset the log context after each worker loop ([#37865](https://github.com/laravel/framework/pull/37865)) +- Improve pretend run Doctrine failure message ([#37879](https://github.com/laravel/framework/pull/37879)) + + +## [v8.49.0 (2021-07-02)](https://github.com/laravel/framework/compare/v8.48.2...v8.49.0) + +### Added +- Add context to subsequent logs ([#37847](https://github.com/laravel/framework/pull/37847)) + + +## [v8.48.2 (2021-06-26)](https://github.com/laravel/framework/compare/v8.48.1...v8.48.2) + +### Added +- Added parameter casting for cursor paginated items ([#37785](https://github.com/laravel/framework/pull/37785), [31ebfc8](https://github.com/laravel/framework/commit/31ebfc86e5c707954b88c43fbe872cb06bc76d28)) +- Added `Illuminate/Http/ResponseTrait::statusText()` ([#37795](https://github.com/laravel/framework/pull/37795)) +- Track a loop variable for sequence and pass it with count to closure ([#37799](https://github.com/laravel/framework/pull/37799)) +- Added "precedence" order to route:list command ([#37824](https://github.com/laravel/framework/pull/37824)) + +### Fixed +- Remove ksort in pool results that modifies intended original order ([#37775](https://github.com/laravel/framework/pull/37775)) +- Make sure availableIn returns positive values in `/Illuminate/Cache/RateLimiter::availableIn()` ([#37809](https://github.com/laravel/framework/pull/37809))- +- Ensure alias is rebound when mocking items in the container in tests ([#37810](https://github.com/laravel/framework/pull/37810)) +- Move primary after collate in `/MySqlGrammar.php` modifiers ([#37815](https://github.com/laravel/framework/pull/37815))) + + +## [v8.48.1 (2021-06-23)](https://github.com/laravel/framework/compare/v8.48.0...v8.48.1) + +### Fixed +- Order of Modifiers Amended in MySqlGrammar ([#37782](https://github.com/laravel/framework/pull/37782)) + + +## [v8.48.0 (2021-06-23)](https://github.com/laravel/framework/compare/v8.47.0...v8.48.0) + +### Added +- Added a queue:prune-failed command ([#37696](https://github.com/laravel/framework/pull/37696), [7aca658](https://github.com/laravel/framework/commit/7aca65833887d0760fc61e320bc46b80c9cb3398)) +- Added `Illuminate/Filesystem/FilesystemManager::build()` ([#37720](https://github.com/laravel/framework/pull/37720), [c21fc12](https://github.com/laravel/framework/commit/c21fc126dc87ff357c7ae5c79014135f693d0ffe)) +- Allow customising the event.stub file ([#37761](https://github.com/laravel/framework/pull/37761)) +- Added `Illuminate/Collections/Collection::sliding()` and `Illuminate/Collections/LazyCollection::sliding()` ([#37751](https://github.com/laravel/framework/pull/37751)) +- Make `Illuminate\Http\Client\Request` macroable ([#37744](https://github.com/laravel/framework/pull/37744)) +- Added GIF, WEBP, WBMP, BMP support to FileFactory::image() ([#37743](https://github.com/laravel/framework/pull/37743)) +- Dispatch 'connection failed' event in http client ([#37740](https://github.com/laravel/framework/pull/37740)) + +### Fixed +- Adds a small fix for unicode with blade echo handlers ([#37697](https://github.com/laravel/framework/pull/37697)) +- Solve the Primary Key issue in databases with sql_require_primary_key enabled ([#37715](https://github.com/laravel/framework/pull/37715)) + +### Changed +- Removed unnecessary checks in RequiredIf validation, fixed tests ([#37700](https://github.com/laravel/framework/pull/37700)) +- Replace non ASCII apostrophe in the email notification template ([#37709](https://github.com/laravel/framework/pull/37709)) +- Change the order of the bindings for a Sql Server query with a offset and a subquery order by ([#37728](https://github.com/laravel/framework/pull/37728), [401928b](https://github.com/laravel/framework/commit/401928b4ba2be400687fdd3c81830b260b51500b)) +- Bind mock instances as singletons so they are not overwritten ([#37746](https://github.com/laravel/framework/pull/37746)) +- Encode objects when casting as JSON ([#37759](https://github.com/laravel/framework/pull/37759)) +- Call on_stats handler in Http stub callbacks ([#37738](https://github.com/laravel/framework/pull/37738)) + + +## [v8.47.0 (2021-06-16)](https://github.com/laravel/framework/compare/v8.46.0...v8.47.0) + +### Added +- Introduce scoped instances ([#37521](https://github.com/laravel/framework/pull/37521), [2971b64](https://github.com/laravel/framework/commit/2971b64ac29bec9e65afe683ab4fcd461c565fe5)) +- Added whereContains AssertableJson method ([#37631](https://github.com/laravel/framework/pull/37631), [2d2d108](https://github.com/laravel/framework/commit/2d2d108a21b21a149c797cb3995c3a25ac9b4be4)) +- Added `Illuminate/Database/Connection::setRecordModificationState()` ([ee1e6b4](https://github.com/laravel/framework/commit/ee1e6b4db76ff11505deb9e5faba3a04de424e97)) +- Added `match()` and `matchAll()` methods to `Illuminate/Support/Str.php` ([#37642](https://github.com/laravel/framework/pull/37642)) +- Copy password rule to current_password ([#37650](https://github.com/laravel/framework/pull/37650)) +- Allow tap() on Paginator ([#37682](https://github.com/laravel/framework/pull/37682)) + +### Revert +- Revert of ["Columns in the order by list must be unique"](https://github.com/laravel/framework/pull/37582) ([#37649](https://github.com/laravel/framework/pull/37649)) + +### Fixed +- Remove illuminate/foundation dependency from Password validation ([#37648](https://github.com/laravel/framework/pull/37648)) +- Fixed callable password defaults in validator ([0b1610f](https://github.com/laravel/framework/commit/0b1610f7a934787856b141205a9f178f33e17f8b)) +- Fixed dns_get_record loose check of A records for active_url rule ([#37675](https://github.com/laravel/framework/pull/37675)) +- Type hinted arguments for Illuminate\Validation\Rules\RequiredIf ([#37688](https://github.com/laravel/framework/pull/37688)) +- Fixed when passed object as parameters to scopes method ([#37692](https://github.com/laravel/framework/pull/37692)) + + +## [v8.46.0 (2021-06-08)](https://github.com/laravel/framework/compare/v8.45.1...v8.46.0) + +### Added +- Allow Custom Notification Stubs ([#37584](https://github.com/laravel/framework/pull/37584)) +- Added methods for indicating the write connection should be used ([94dbf76](https://github.com/laravel/framework/commit/94dbf768fa46917cb012a05b38cbc889dbd2e8a0)) +- Added timestamp reference to schedule:run artisan command output ([#37591](https://github.com/laravel/framework/pull/37591)) +- Columns in the order by list must be unique ([#37582](https://github.com/laravel/framework/pull/37582)) + +### Changed +- Fire a trashed model event and listen to it for broadcasting events ([#37618](https://github.com/laravel/framework/pull/37618)) +- Cast JSON strings containing single quotes ([#37619](https://github.com/laravel/framework/pull/37619)) + +### Fixed +- Fixed for cloning issues with PendingRequest object ([#37596](https://github.com/laravel/framework/pull/37596), [96518b9](https://github.com/laravel/framework/commit/96518b9bbbc6e984f879c535502c199ef022f52a)) +- Makes the retrieval of Http client transferStats safe ([#37597](https://github.com/laravel/framework/pull/37597)) +- Fixed inconsistency in table names in validator ([#37606](https://github.com/laravel/framework/pull/37606)) +- Fixes for Stringable for views ([#37613](https://github.com/laravel/framework/pull/37613)) +- Fixed one-of-many bindings ([#37616](https://github.com/laravel/framework/pull/37616)) +- Fixed infinity loop on transaction committed ([#37626](https://github.com/laravel/framework/pull/37626)) +- Added missing fix to DatabaseRule::resolveTableName fix #37580 ([#37621](https://github.com/laravel/framework/pull/37621)) + + +## [v8.45.1 (2021-06-03)](https://github.com/laravel/framework/compare/v8.45.0...v8.45.1) + +### Revert +- Revert of ["Columns in the order by list must be unique"](https://github.com/laravel/framework/pull/37550) ([dc2f0bb](https://github.com/laravel/framework/commit/dc2f0bb02c3eb4b27669d626bb3e810db8e7749d)) + + +## [v8.45.0 (2021-06-03)](https://github.com/laravel/framework/compare/v8.44.0...v8.45.0) + +### Added +- Introduce Conditional trait ([#37504](https://github.com/laravel/framework/pull/37504), [45ff23c](https://github.com/laravel/framework/commit/45ff23c6174416f63ea7dbd77bc7fe8aafced86b), [#37561](https://github.com/laravel/framework/pull/37561)) +- Allow multiple SES configuration with IAM Role authentication ([#37523](https://github.com/laravel/framework/pull/37523)) +- Adds class handling for Blade echo statements ([#37478](https://github.com/laravel/framework/pull/37478)) +- Added `Illuminate/Session/DatabaseSessionHandler::setContainer()` ([7a71c29](https://github.com/laravel/framework/commit/7a71c292c0ae656c622cff883638e77de6f0bfde)) +- Allow connecting to read or write connections with the db command ([#37548](https://github.com/laravel/framework/pull/37548)) +- Added assertDownloadOffered test method to TestResponse class ([#37532](https://github.com/laravel/framework/pull/37532)) +- Added `Illuminate/Http/Client/Response::close()` ([#37566](https://github.com/laravel/framework/pull/37566)) +- Allow setting middleware on queued Mailables ([#37568](https://github.com/laravel/framework/pull/37568)) +- Adds new RequestSent and ResponseReceived events to the HTTP Client ([#37572](https://github.com/laravel/framework/pull/37572)) + +### Changed +- Rename protected method `Illuminate/Foundation/Console/StorageLinkCommand::removableSymlink()` to `Illuminate/Foundation/Console/StorageLinkCommand::isRemovableSymlink()` ([#37508](https://github.com/laravel/framework/pull/37508)) +- Correct minimum Predis version to 1.1.2 ([#37554](https://github.com/laravel/framework/pull/37554)) +- Columns in the order by list must be unique ([#37550](https://github.com/laravel/framework/pull/37550)) +- More Convenient Model Broadcasting ([#37491](https://github.com/laravel/framework/pull/37491)) + +### Fixed +- Get queueable relationship when collection has non-numeric keys ([#37556](https://github.com/laravel/framework/pull/37556)) + + +## [v8.44.0 (2021-05-27)](https://github.com/laravel/framework/compare/v8.43.0...v8.44.0) + +### Added +- Delegate lazy loading violation to method ([#37480](https://github.com/laravel/framework/pull/37480)) +- Added `force` option to `Illuminate/Foundation/Console/StorageLinkCommand` ([#37501](https://github.com/laravel/framework/pull/37501), [3e547d2](https://github.com/laravel/framework/commit/3e547d2f276f9242d3856ff9cb02418560ae9a1b)) + +### Fixed +- Fixed aggregates with having ([#37487](https://github.com/laravel/framework/pull/37487), [c986e12](https://github.com/laravel/framework/commit/c986e12b00e9569cca5e24e5072e7770ffc25efa)) +- Bugfix passing errorlevel when command is run in background ([#37479](https://github.com/laravel/framework/pull/37479)) + +### Changed +- Init the traits when the model is being unserialized ([#37492](https://github.com/laravel/framework/pull/37492)) +- Relax the lazy loading restrictions ([#37503](https://github.com/laravel/framework/pull/37503)) + + +## [v8.43.0 (2021-05-25)](https://github.com/laravel/framework/compare/v8.42.1...v8.43.0) + +### Added +- Added `Illuminate\Auth\Authenticatable::getAuthIdentifierForBroadcasting()` ([#37408](https://github.com/laravel/framework/pull/37408)) +- Added eloquent strict loading mode ([#37363](https://github.com/laravel/framework/pull/37363)) +- Added default timeout to NotPwnedVerifier validator ([#37440](https://github.com/laravel/framework/pull/37440), [45567e0](https://github.com/laravel/framework/commit/45567e0c0707bb2b418a4218e62fa85e478a68d9)) +- Added beforeQuery to base query builder ([#37431](https://github.com/laravel/framework/pull/37431)) +- Added `Illuminate\Queue\Jobs\Job::shouldFailOnTimeout()` ([#37450](https://github.com/laravel/framework/pull/37450)) +- Added `ValidatorAwareRule` interface ([#37442](https://github.com/laravel/framework/pull/37442)) +- Added model support for database assertions ([#37459](https://github.com/laravel/framework/pull/37459)) + +### Fixed +- Fixed eager loading one-of-many relationships with multiple aggregates ([#37436](https://github.com/laravel/framework/pull/37436)) + +### Changed +- Improve signed url signature verification ([#37432](https://github.com/laravel/framework/pull/37432)) +- Improve one-of-many performance ([#37451](https://github.com/laravel/framework/pull/37451)) +- Update `Illuminate/Pagination/Cursor::parameter()` ([#37458](https://github.com/laravel/framework/pull/37458)) +- Reconnect the correct connection when using ::read or ::write ([#37471](https://github.com/laravel/framework/pull/37471), [d1a32f9](https://github.com/laravel/framework/commit/d1a32f9acb225b6b7b360736f3c717461220dac9)) + + +## [v8.42.1 (2021-05-19)](https://github.com/laravel/framework/compare/v8.42.0...v8.42.1) + +### Added +- Add default "_of_many" to join alias when relation name is table name ([#37411](https://github.com/laravel/framework/pull/37411)) + +### Changed +- Allow dababase password to be null in `MySqlSchemaState` ([#37418](https://github.com/laravel/framework/pull/37418)) +- Accept any instance of Rule and not just Password in password rule ([#37407](https://github.com/laravel/framework/pull/37407)) + +### Fixed +- Fixed aggregates (e.g.: withExists) for one of many relationships ([#37413](https://github.com/laravel/framework/pull/37413), [498e1a0](https://github.com/laravel/framework/commit/498e1a064f0a60b68047a1d3f7c544d14c356503)) + + +## [v8.42.0 (2021-05-18)](https://github.com/laravel/framework/compare/v8.41.0...v8.42.0) + +### Added +- Support views in SQLServerGrammar ([#37348](https://github.com/laravel/framework/pull/37348)) +- Added new assertDispatchedSync methods to BusFake ([#37350](https://github.com/laravel/framework/pull/37350), [414f382](https://github.com/laravel/framework/commit/414f38247a084fad3dd63b2106968eb119a3d447)) +- Added withExists method to QueriesRelationships ([#37302](https://github.com/laravel/framework/pull/37302)) +- Added ability to define default Password Rule ([#37387](https://github.com/laravel/framework/pull/37387), [f7e5b1c](https://github.com/laravel/framework/commit/f7e5b1c105dec980b3206c0b9bc7db735756b8d5)) +- Allow sending a refresh header with maintenance mode response ([#37385](https://github.com/laravel/framework/pull/37385)) +- Added loadExists on Model and Eloquent Collection ([#37388](https://github.com/laravel/framework/pull/37388)) +- Added one-of-many relationship (inner join) ([#37362](https://github.com/laravel/framework/pull/37362)) + +### Changed +- Avoid deprecated guzzle code ([#37349](https://github.com/laravel/framework/pull/37349)) +- Make AssertableJson easier to extend by replacing self with static ([#37380](https://github.com/laravel/framework/pull/37380)) +- Raise ScheduledBackgroundTaskFinished event to signal when a run in background task finishes ([#37377](https://github.com/laravel/framework/pull/37377)) + + +## [v8.41.0 (2021-05-11)](https://github.com/laravel/framework/compare/v8.40.0...v8.41.0) + +### Added +- Added `Illuminate\Database\Eloquent\Model::updateQuietly()` ([#37169](https://github.com/laravel/framework/pull/37169)) +- Added `Illuminate\Support\Str::replace()` ([#37186](https://github.com/laravel/framework/pull/37186)) +- Added Model key extraction to id on whereKey() and whereKeyNot() ([#37184](https://github.com/laravel/framework/pull/37184)) +- Added support for Pusher 6.x ([#37223](https://github.com/laravel/framework/pull/37223), [819db15](https://github.com/laravel/framework/commit/819db15a79621a93f26b4790dc944a74f7a04489)) +- Added `Illuminate/Foundation/Http/Kernel::getMiddlewarePriority()` ([#37271](https://github.com/laravel/framework/pull/37271)) +- Added cursor pagination (aka keyset pagination) ([#37216](https://github.com/laravel/framework/pull/37216), [#37315](https://github.com/laravel/framework/pull/37315)) +- Support mass assignment to SQL Server views ([#37307](https://github.com/laravel/framework/pull/37307)) +- Added `Illuminate/Support/Stringable::unless()` ([#37326](https://github.com/laravel/framework/pull/37326)) + +### Fixed +- Fixed `Illuminate\Database\Query\Builder::offset()` with non numbers $value ([#37164](https://github.com/laravel/framework/pull/37164)) +- Treat missing UUID in failed Queue Job as empty string (failed driver = database) ([#37251](https://github.com/laravel/framework/pull/37251)) +- Fixed fields not required with required_unless ([#37262](https://github.com/laravel/framework/pull/37262)) +- SqlServer Grammar: Bugfixes for hasTable and dropIfExists / support for using schema names in these functions ([#37280](https://github.com/laravel/framework/pull/37280)) +- Fix PostgreSQL dump and load for Windows ([#37320](https://github.com/laravel/framework/pull/37320)) + +### Changed +- Add fallback when migration is not anonymous class ([#37166](https://github.com/laravel/framework/pull/37166)) +- Ably expects clientId as string in `Illuminate\Broadcasting\Broadcasters\AblyBroadcaster::validAuthenticationResponse()` ([#37249](https://github.com/laravel/framework/pull/37249)) +- Computing controller middleware before getting excluding middleware ([#37259](https://github.com/laravel/framework/pull/37259)) +- Update mime extension check ([#37332](https://github.com/laravel/framework/pull/37332)) +- Added exception to chunkById() when last id cannot be determined ([#37294](https://github.com/laravel/framework/pull/37294)) + + +## [v8.40.0 (2021-04-28)](https://github.com/laravel/framework/compare/v8.39.0...v8.40.0) + +### Added +- Added `Illuminate\Database\Eloquent\Builder::withOnly()` ([#37144](https://github.com/laravel/framework/pull/37144)) +- Added `Illuminate\Bus\PendingBatch::add()` ([#37151](https://github.com/laravel/framework/pull/37151)) + +### Fixed +- Fixed Cache store with a name other than 'dynamodb' ([#37145](https://github.com/laravel/framework/pull/37145)) + +### Changed +- Added has environment variable to startProcess method in `ServeCommand` ([#37142](https://github.com/laravel/framework/pull/37142)) +- Some cast to int in `Illuminate\Database\Query\Grammars\SqlServerGrammar` ([09bf145](https://github.com/laravel/framework/commit/09bf1457e9df53e172e6fd5929cbafb539677c7c)) + + +## [v8.39.0 (2021-04-27)](https://github.com/laravel/framework/compare/v8.38.0...v8.39.0) + +### Added +- Added `Illuminate\Collections\Collection::sole()` method ([#37034](https://github.com/laravel/framework/pull/37034)) +- Support `url` for php artisan db command ([#37064](https://github.com/laravel/framework/pull/37064)) +- Added `Illuminate\Foundation\Bus\DispatchesJobs::dispatchSync()` ([#37063](https://github.com/laravel/framework/pull/37063)) +- Added `Illuminate\Cookie\CookieJar::expire()` ([#37072](https://github.com/laravel/framework/pull/37072), [fa3a14f](https://github.com/laravel/framework/commit/fa3a14f4da763a9a95162dc4092d5ab7356e0cb8)) +- Added `Illuminate\Database\DatabaseManager::setApplication()` ([#37068](https://github.com/laravel/framework/pull/37068)) +- Added `Illuminate\Support\Stringable::whenNotEmpty()` ([#37080](https://github.com/laravel/framework/pull/37080)) +- Added `Illuminate\Auth\SessionGuard::attemptWhen()` ([#37090](https://github.com/laravel/framework/pull/37090), [e3fcd97](https://github.com/laravel/framework/commit/e3fcd97d16a064d39c419201937fcc299d6bfa2e)) +- Added password validation rule ([#36960](https://github.com/laravel/framework/pull/36960)) + +### Fixed +- Fixed `JsonResponse::fromJsonString()` double encoding string ([#37076](https://github.com/laravel/framework/pull/37076)) +- Fallback to primary key if owner key doesnt exist on model at all in `MorphTo` relation ([a011109](https://github.com/laravel/framework/commit/a0111098c039c27a76df4b4dd555f351ee3c81eb)) +- Fixes for PHP 8.1 ([#37087](https://github.com/laravel/framework/pull/37087), [#37101](https://github.com/laravel/framework/pull/37101)) +- Do not execute beforeSending callbacks twice in HTTP client ([#37116](https://github.com/laravel/framework/pull/37116)) +- Fixed nullable values for required_if ([#37128](https://github.com/laravel/framework/pull/37128), [86fd558](https://github.com/laravel/framework/commit/86fd558b4e5d8d7d45cf457cd1a72d54334297a1)) + +### Changed +- Schedule list timezone command ([#37117](https://github.com/laravel/framework/pull/37117)) + + +## [v8.38.0 (2021-04-20)](https://github.com/laravel/framework/compare/v8.37.0...v8.38.0) + +### Added +- Added a `wordCount()` string helper ([#36990](https://github.com/laravel/framework/pull/36990)) +- Allow anonymous and class based migration coexisting ([#37006](https://github.com/laravel/framework/pull/37006)) +- Added `Illuminate\Broadcasting\Broadcasters\PusherBroadcaster::setPusher()` ([#37033](https://github.com/laravel/framework/pull/37033)) + +### Fixed +- Fixed required_if boolean validation ([#36969](https://github.com/laravel/framework/pull/36969)) +- Correctly merge object payload data in `Illuminate\Queue\Queue::createObjectPayload()` ([#36998](https://github.com/laravel/framework/pull/36998)) +- Allow the use of temporary views for Blade testing on Windows machines ([#37044](https://github.com/laravel/framework/pull/37044)) +- Fixed `Http::withBody()` not being sent ([#37057](https://github.com/laravel/framework/pull/37057)) + + +## [v8.37.0 (2021-04-13)](https://github.com/laravel/framework/compare/v8.36.2...v8.37.0) + +### Added +- Allow to retry jobs by queue name ([#36898](https://github.com/laravel/framework/pull/36898), [f2d9b59](https://github.com/laravel/framework/commit/f2d9b595e51d564c5e1390eb42438c632e0daf36), [c351a30](https://github.com/laravel/framework/commit/c351a309f1a02098f9a7ee24a8a402e9ce06fead)) +- Added strings to the `DetectsLostConnections.php` ([4210258](https://github.com/laravel/framework/commit/42102589bc7f7b8533ee1b815ef0cc18017d4e45)) +- Allow testing of Blade components that return closures ([#36919](https://github.com/laravel/framework/pull/36919)) +- Added anonymous migrations ([#36906](https://github.com/laravel/framework/pull/36906)) +- Added `Session\Store::missing()` method ([#36937](https://github.com/laravel/framework/pull/36937)) +- Handle concurrent asynchronous requests in the HTTP client ([#36948](https://github.com/laravel/framework/pull/36948), [245a712](https://github.com/laravel/framework/commit/245a7125076e52da7ce55b494c1c01f0f28df55d)) +- Added tinyText data type to Blueprint and to available database grammars ([#36949](https://github.com/laravel/framework/pull/36949)) +- Added a method to remove a resolved view engine ([#36955](https://github.com/laravel/framework/pull/36955)) +- Added `Illuminate\Database\Eloquent\Model::getAttributesForInsert()` protected method ([9a9f59f](https://github.com/laravel/framework/commit/9a9f59fcc6e7b93465ce9848b52a473477dff64a), [314bf87](https://github.com/laravel/framework/commit/314bf875ba5d37c056ccea5148181fcb0517f596)) + +### Fixed +- Fixed clone() on EloquentBuilder ([#36924](https://github.com/laravel/framework/pull/36924)) + +### Changed +- `Model::delete()` throw LogicException not Exception ([#36914](https://github.com/laravel/framework/pull/36914)) +- Make pagination linkCollection() method public ([#36959](https://github.com/laravel/framework/pull/36959)) + + +## [v8.36.2 (2021-04-07)](https://github.com/laravel/framework/compare/v8.36.1...v8.36.2) + +### Revert +- Revert blade changes ([#36902](https://github.com/laravel/framework/pull/36902)) + + +## [v8.36.1 (2021-04-07)](https://github.com/laravel/framework/compare/v8.36.0...v8.36.1) + +### Fixed +- Fixed escaping within quoted strings in blade ([#36893](https://github.com/laravel/framework/pull/36893)) + +### Changed +- Call transaction callbacks after updating the transaction level ([#36890](https://github.com/laravel/framework/pull/36890), [#36892](https://github.com/laravel/framework/pull/36892)) +- Support maxExceptions option on queued listeners ([#36891](https://github.com/laravel/framework/pull/36891)) + + +## [v8.36.0 (2021-04-06)](https://github.com/laravel/framework/compare/v8.35.1...v8.36.0) + +### Revert +- Revert ["[8.x] Allow lazy collection to be instantiated from a generator"](https://github.com/laravel/framework/pull/36738) ([#36844](https://github.com/laravel/framework/pull/36844)) + +### Added +- Added support useCurrentOnUpdate for MySQL datetime column types ([#36817](https://github.com/laravel/framework/pull/36817)) +- Added `dispatch_sync()` helper ([#36835](https://github.com/laravel/framework/pull/36835)) +- Allowing skipping TransformRequests middlewares via Closure ([#36856](https://github.com/laravel/framework/pull/36856)) +- Added type option to make controller command ([#36853](https://github.com/laravel/framework/pull/36853)) +- Added missing return $this to `Illuminate\Support\Manager::forgetDrivers()` ([#36859](https://github.com/laravel/framework/pull/36859)) +- Added unfinished option to PruneBatchesCommand ([#36877](https://github.com/laravel/framework/pull/36877)) +- Added a simple Str::repeat() helper function ([#36887](https://github.com/laravel/framework/pull/36887)) + +### Fixed +- Fixed getMultiple and increment / decrement on tagged cache ([0d21194](https://github.com/laravel/framework/commit/0d211947da9ad222fa8eb092889bb7d7f5fb1726)) +- Implement proper return types in cache increment and decrement ([#36836](https://github.com/laravel/framework/pull/36836)) +- Fixed blade compiler regex issue ([#36843](https://github.com/laravel/framework/pull/36843), [#36848](https://github.com/laravel/framework/pull/36848)) +- Added missing temporary_url when creating flysystem ([#36860](https://github.com/laravel/framework/pull/36860)) +- Fixed PostgreSQL schema:dump when read/write hosts are arrays ([#36881](https://github.com/laravel/framework/pull/36881)) + +### Changed +- Improve the exception thrown when JSON encoding response contents fails in `Response::setContent()` ([#36851](https://github.com/laravel/framework/pull/36851), [#36868](https://github.com/laravel/framework/pull/36868)) +- Revert isDownForMaintenance function to use file_exists() ([#36889](https://github.com/laravel/framework/pull/36889)) + + +## [v8.35.1 (2021-03-31)](https://github.com/laravel/framework/compare/v8.35.0...v8.35.1) + +### Fixed +- Fixed setting DynamoDB credentials ([#36822](https://github.com/laravel/framework/pull/36822)) + + +## [v8.35.0 (2021-03-30)](https://github.com/laravel/framework/compare/v8.34.0...v8.35.0) + +### Added +- Added support of DynamoDB in CI suite ([#36749](https://github.com/laravel/framework/pull/36749)) +- Support username parameter for predis ([#36762](https://github.com/laravel/framework/pull/36762)) +- Added missing months() to Wormhole ([#36808](https://github.com/laravel/framework/pull/36808)) + +### Deprecated +- Deprecate MocksApplicationServices trait ([#36716](https://github.com/laravel/framework/pull/36716)) + +### Fixed +- Fixes missing lazy() and lazyById() on BelongsToMany and HasManyThrough relation query builder ([#36758](https://github.com/laravel/framework/pull/36758)) +- Ensure the compiled view directory exists ([#36772](https://github.com/laravel/framework/pull/36772)) +- Fix Artisan test method PendingCommand::doesntExpectOutput() always causing a failed test ([#36806](https://github.com/laravel/framework/pull/36806)) +- FIXED: The use of whereHasMorph in a whereHas callback generates a wrong sql statements ([#36801](https://github.com/laravel/framework/pull/36801)) + +### Changed +- Allow lazy collection to be instantiated from a generator ([#36738](https://github.com/laravel/framework/pull/36738)) +- Use qualified column names in pivot query ([#36720](https://github.com/laravel/framework/pull/36720)) +- Octane Prep ([#36777](https://github.com/laravel/framework/pull/36777)) + +### Refactoring +- Remove useless loop in `Str::remove()` ([#36722](https://github.com/laravel/framework/pull/36722)) + + +## [v8.34.0 (2021-03-23)](https://github.com/laravel/framework/compare/v8.33.1...v8.34.0) + +### Inspiring +- Added more inspiring quotes ([92b7bde](https://github.com/laravel/framework/commit/92b7bdeb4b8c40848fa276cfe1897c656302942f)) + +### Added +- Added WSREP communication link failure for lost connection detection ([#36668](https://github.com/laravel/framework/pull/36668)) +- Added "except-path" option to `route:list` command ([#36619](https://github.com/laravel/framework/pull/36619), [76e11ee](https://github.com/laravel/framework/commit/76e11ee97fc8068be1d55986b4524d4c329af387)) +- Added `Illuminate\Support\Str::remove()` and `Illuminate\Support\Stringable::remove()` methods ([#36639](https://github.com/laravel/framework/pull/36639), [7b0259f](https://github.com/laravel/framework/commit/7b0259faa46409513b75a8a0b512b3aacfcad944), [20e2470](https://github.com/laravel/framework/commit/20e24701e71f71a44b477b4311d0cb69f97906f1)) +- Added `Illuminate\Database\Eloquent\Relations\MorphPivot::getMorphType()` ([#36640](https://github.com/laravel/framework/pull/36640), [7e08215](https://github.com/laravel/framework/commit/7e08215f0d370c3c33beb7bba7e2c1ee2ac7aab5)) +- Added assertion to verify type of key in JSON ([#36638](https://github.com/laravel/framework/pull/36638)) +- Added prohibited validation rule ([#36667](https://github.com/laravel/framework/pull/36667)) +- Added strict comparison to distinct validation rule ([#36669](https://github.com/laravel/framework/pull/36669)) +- Added `Illuminate\Translation\FileLoader::getJsonPaths()` ([#36689](https://github.com/laravel/framework/pull/36689)) +- Added `Illuminate\Support\Testing\Fakes\EventFake::assertAttached()` ([#36690](https://github.com/laravel/framework/pull/36690)) +- Added `lazy()` and `lazyById()` methods to `Illuminate\Database\Concerns\BuildsQueries` ([#36699](https://github.com/laravel/framework/pull/36699)) + +### Fixed +- Fixes the issue using cache:clear with PhpRedis and a clustered Redis instance. ([#36665](https://github.com/laravel/framework/pull/36665)) +- Fix replacing required :input with null on PHP 8.1 in `Illuminate\Validation\Concerns\FormatsMessages::getDisplayableValue()` ([#36622](https://github.com/laravel/framework/pull/36622)) +- Fixed artisan schema:dump error ([#36698](https://github.com/laravel/framework/pull/36698)) + +### Changed +- Adjust Fluent Assertions ([#36620](https://github.com/laravel/framework/pull/36620)) +- Added timestamp reference to schedule:work artisan command output ([#36621](https://github.com/laravel/framework/pull/36621)) +- Expect custom markdown mailable themes to be in mail subdirectory ([#36673](https://github.com/laravel/framework/pull/36673)) +- Throw exception when unable to create LockableFile ([#36674](https://github.com/laravel/framework/pull/36674)) + +### Refactoring +- Always prefer typesafe string comparisons ([#36657](https://github.com/laravel/framework/pull/36657)) + + +## [v8.33.1 (2021-03-16)](https://github.com/laravel/framework/compare/v8.33.0...v8.33.1) + +### Added +- Added `Illuminate\Database\Connection::forgetRecordModificationState()` ([#36617](https://github.com/laravel/framework/pull/36617)) + +### Reverted +- Reverted "Container - detect circular dependencies" ([332844e](https://github.com/laravel/framework/commit/332844e5bde34f8db91aeca4d21cd4e0925d691e)) + + +## [v8.33.0 (2021-03-16)](https://github.com/laravel/framework/compare/v8.32.1...v8.33.0) + +### Added +- Added broken pipe exception as lost connection error ([#36601](https://github.com/laravel/framework/pull/36601)) +- Added missing option to resource ([#36562](https://github.com/laravel/framework/pull/36562)) +- Introduce StringEncrypter interface ([#36578](https://github.com/laravel/framework/pull/36578)) + +### Fixed +- Fixed returns with Mail & Notification components ([#36559](https://github.com/laravel/framework/pull/36559)) +- Stack driver fix: respect the defined processors in LogManager ([#36591](https://github.com/laravel/framework/pull/36591)) +- Require the correct password to rehash it when logging out other devices ([#36608](https://github.com/laravel/framework/pull/36608), [1e61612](https://github.com/laravel/framework/commit/1e6161250074b8106c1fcf153eeaef7c0bf74c6c)) + +### Changed +- Allow nullable columns for `AsArrayObject/AsCollection` casts ([#36526](https://github.com/laravel/framework/pull/36526)) +- Accept callable class for reportable and renderable in exception handler ([#36551](https://github.com/laravel/framework/pull/36551)) +- Container - detect circular dependencies ([dd7274d](https://github.com/laravel/framework/commit/dd7274d23a9ee58cc1abdf7107403169a3994b68), [a712f72](https://github.com/laravel/framework/commit/a712f72ca88f709335576530b31635738abd4c89), [6f9bb4c](https://github.com/laravel/framework/commit/6f9bb4cdd84295cbcf7908cc4b4684f47f38b8cf)) +- Initialize CronExpression class using new keyword ([#36600](https://github.com/laravel/framework/pull/36600)) +- Use different config key for overriding temporary url host in AwsTemporaryUrl method ([#36612](https://github.com/laravel/framework/pull/36612)) + + +## [v8.32.1 (2021-03-09)](https://github.com/laravel/framework/compare/v8.32.0...v8.32.1) + +### Changed +- Changed `Illuminate\Queue\Middleware\ThrottlesExceptions` ([b8a70e9](https://github.com/laravel/framework/commit/b8a70e9a3685871ed46a24fc03c0267849d2d7c8)) + + +## [v8.32.0 (2021-03-09)](https://github.com/laravel/framework/compare/v8.31.0...v8.32.0) + +Added +- Phpredis lock serialization and compression support ([#36412](https://github.com/laravel/framework/pull/36412), [10f1a93](https://github.com/laravel/framework/commit/10f1a935205340ba8954e7075c1d9b67943db27d)) +- Added Fluent JSON Assertions ([#36454](https://github.com/laravel/framework/pull/36454)) +- Added methods to dump requests of the Laravel HTTP client ([#36466](https://github.com/laravel/framework/pull/36466)) +- Added `ThrottlesExceptions` and `ThrottlesExceptionsWithRedis` job middlewares for unstable services ([#36473](https://github.com/laravel/framework/pull/36473), [21fee76](https://github.com/laravel/framework/commit/21fee7649e1b48a7701b8ba860218741c2c3bcef), [36518](https://github.com/laravel/framework/pull/36518), [37e48ba](https://github.com/laravel/framework/commit/37e48ba864e2f463517429d41cefd94e88136c1c)) +- Added support to Eloquent Collection on `Model::destroy()` ([#36497](https://github.com/laravel/framework/pull/36497)) +- Added `rest` option to `php artisan queue:work` command ([#36521](https://github.com/laravel/framework/pull/36521), [c6ea49c](https://github.com/laravel/framework/commit/c6ea49c80a2ac93aebb8fdf2360161b73cec26af)) +- Added `prohibited_if` and `prohibited_unless` validation rules ([#36516](https://github.com/laravel/framework/pull/36516)) +- Added class `argument` to `Illuminate\Database\Console\Seeds\SeedCommand` ([#36513](https://github.com/laravel/framework/pull/36513)) + +### Fixed +- Fix validator treating null as true for (required|exclude)_(if|unless) due to loose `in_array()` check ([#36504](https://github.com/laravel/framework/pull/36504)) + +### Changed +- Delete existing links that are broken in `Illuminate\Foundation\Console\StorageLinkCommand` ([#36470](https://github.com/laravel/framework/pull/36470)) +- Use user provided url in AwsTemporaryUrl method ([#36480](https://github.com/laravel/framework/pull/36480)) +- Allow to override discover events base path ([#36515](https://github.com/laravel/framework/pull/36515)) + + +## [v8.31.0 (2021-03-04)](https://github.com/laravel/framework/compare/v8.30.1...v8.31.0) + +### Added +- Added new `VendorTagPublished` event ([#36458](https://github.com/laravel/framework/pull/36458)) +- Added new `Stringable::test()` method ([#36462](https://github.com/laravel/framework/pull/36462)) + +### Reverted +- Reverted [Fixed `formatWheres()` methods in `DatabaseRule`](https://github.com/laravel/framework/pull/36441) ([#36452](https://github.com/laravel/framework/pull/36452)) + +### Changed +- Make user policy command fix (Windows) ([#36464](https://github.com/laravel/framework/pull/36464)) + + +## [v8.30.1 (2021-03-03)](https://github.com/laravel/framework/compare/v8.30.0...v8.30.1) + +### Reverted +- Reverted [Respect custom route key with explicit route model binding](https://github.com/laravel/framework/pull/36375) ([#36449](https://github.com/laravel/framework/pull/36449)) + +### Fixed +- Fixed `formatWheres()` methods in `DatabaseRule` ([#36441](https://github.com/laravel/framework/pull/36441)) + + +## [v8.30.0 (2021-03-02)](https://github.com/laravel/framework/compare/v8.29.0...v8.30.0) + +### Added +- Added new line to `DetectsLostConnections` ([#36373](https://github.com/laravel/framework/pull/36373)) +- Added `Illuminate\Cache\RateLimiting\Limit::perMinutes()` ([#36352](https://github.com/laravel/framework/pull/36352), [86d0a5c](https://github.com/laravel/framework/commit/86d0a5c733b3f22ae2353df538e07605963c3052)) +- Make Database Factory macroable ([#36380](https://github.com/laravel/framework/pull/36380)) +- Added stop on first failure for Validators ([39e1f84](https://github.com/laravel/framework/commit/39e1f84a48fec024859d4e80948aca9bd7878658)) +- Added `containsOneItem()` method to Collections ([#36428](https://github.com/laravel/framework/pull/36428), [5b7ffc2](https://github.com/laravel/framework/commit/5b7ffc2b54dec803bd12541ab9c3d6bf3d4666ca)) + +### Changed +- Respect custom route key with explicit route model binding ([#36375](https://github.com/laravel/framework/pull/36375)) +- Add Buffered Console Output ([#36404](https://github.com/laravel/framework/pull/36404)) +- Don't flash 'current_password' input ([#36415](https://github.com/laravel/framework/pull/36415)) +- Check for context method in Exception Handler ([#36424](https://github.com/laravel/framework/pull/36424)) + + +## [v8.29.0 (2021-02-23)](https://github.com/laravel/framework/compare/v8.28.1...v8.29.0) + +### Added +- Support username parameter for predis ([#36299](https://github.com/laravel/framework/pull/36299)) +- Adds "setUpTestDatabase" support to Parallel Testing ([#36301](https://github.com/laravel/framework/pull/36301)) +- Added support closures in sequences ([3c66f6c](https://github.com/laravel/framework/commit/3c66f6cda2ac4ee2844a67fc98e676cb170ff4b1)) +- Added gate evaluation event ([0c6f5f7](https://github.com/laravel/framework/commit/0c6f5f75bf0ba4d3307145c9d92ae022f60414be)) +- Added a `collect` method to the HTTP Client response ([#36331](https://github.com/laravel/framework/pull/36331)) +- Allow Blade's service injection to inject services typed using class name resolution ([#36356](https://github.com/laravel/framework/pull/36356)) + +### Fixed +- Fixed: Using withoutMiddleware() and a closure-based middleware on PHP8 throws an exception ([#36293](https://github.com/laravel/framework/pull/36293)) +- Fixed: The label for page number in pagination links should always be a string ([#36292](https://github.com/laravel/framework/pull/36292)) +- Clean up custom Queue payload between tests ([#36295](https://github.com/laravel/framework/pull/36295)) +- Fixed flushDb (cache:clear) for redis clusters ([#36281](https://github.com/laravel/framework/pull/36281)) +- Fixed retry command for encrypted jobs ([#36334](https://github.com/laravel/framework/pull/36334), [2fb5e44](https://github.com/laravel/framework/commit/2fb5e444ef55a764ba2363a10320e75f3c830504)) +- Make sure `trait_uses_recursive` returns an array ([#36335](https://github.com/laravel/framework/pull/36335)) + +### Changed +- Make use of specified ownerKey in MorphTo::associate() ([#36303](https://github.com/laravel/framework/pull/36303)) +- Update pusher deps and update broadcasting ([3404185](https://github.com/laravel/framework/commit/3404185fbe36139dfbe6d0d9595811b41ee53068)) + + +## [v8.28.1 (2021-02-16)](https://github.com/laravel/framework/compare/v8.28.0...v8.28.1) + +### Fixed +- Revert "[8.x] Clean up custom Queue payload between tests" ([#36287](https://github.com/laravel/framework/pull/36287)) + + +## [v8.28.0 (2021-02-16)](https://github.com/laravel/framework/compare/v8.27.0...v8.28.0) + +### Added +- Allow users to specify configuration keys to be used for primitive binding ([#36241](https://github.com/laravel/framework/pull/36241)) +- ArrayObject + Collection Custom Casts ([#36245](https://github.com/laravel/framework/pull/36245)) +- Add view path method ([af3a651](https://github.com/laravel/framework/commit/af3a651ad6ae3e90bd673fe7a6bfc1ce9e569d25)) + +### Changed +- Allow using dot syntax for `$responseKey` ([#36196](https://github.com/laravel/framework/pull/36196)) +- Full trace for http errors ([#36219](https://github.com/laravel/framework/pull/36219)) + +### Fixed +- Fix undefined property with sole query ([#36216](https://github.com/laravel/framework/pull/36216)) +- Resolving non-instantiables corrupts `Container::$with` ([#36212](https://github.com/laravel/framework/pull/36212)) +- Fix attribute nesting on anonymous components ([#36240](https://github.com/laravel/framework/pull/36240)) +- Ensure `$prefix` is a string ([#36254](https://github.com/laravel/framework/pull/36254)) +- Add missing import ([#34569](https://github.com/laravel/framework/pull/34569)) +- Align PHP 8.1 behavior of `e()` ([#36262](https://github.com/laravel/framework/pull/36262)) +- Ensure null values won't break on PHP 8.1 ([#36264](https://github.com/laravel/framework/pull/36264)) +- Handle directive `$value` as a string ([#36260](https://github.com/laravel/framework/pull/36260)) +- Use explicit flag as default sorting ([#36261](https://github.com/laravel/framework/pull/36261)) +- Fix middleware group display ([d9e28dc](https://github.com/laravel/framework/commit/d9e28dcb1f4a5638b33829d919bd7417321ab39e)) + + +## [v8.27.0 (2021-02-09)](https://github.com/laravel/framework/compare/v8.26.1...v8.27.0) + +### Added +- Conditionally merge classes into a Blade Component attribute bag ([#36131](https://github.com/laravel/framework/pull/36131)) +- Allow adding multiple columns after a column ([#36145](https://github.com/laravel/framework/pull/36145)) +- Add query builder `chunkMap` method ([#36193](https://github.com/laravel/framework/pull/36193), [048ac6d](https://github.com/laravel/framework/commit/048ac6d49f2f7b2d64eb1695848df4590c38be98)) + +### Changed +- Update CallQueuedClosure to catch Throwable/Error ([#36159](https://github.com/laravel/framework/pull/36159)) +- Allow components to use custom attribute bag ([#36186](https://github.com/laravel/framework/pull/36186)) + +### Fixed +- Set process timeout to null for load mysql schema into database ([#36126](https://github.com/laravel/framework/pull/36126)) +- Don't pluralise string if string ends with none alphanumeric character ([#36137](https://github.com/laravel/framework/pull/36137)) +- Add query log methods to the DB facade ([#36177](https://github.com/laravel/framework/pull/36177)) +- Add commonmark as recommended package for `Illuminate\Support` ([#36171](https://github.com/laravel/framework/pull/36171)) +- Fix Eager loading partially nullable morphTo relations ([#36129](https://github.com/laravel/framework/pull/36129)) +- Make height of image working with yahoo ([#36201](https://github.com/laravel/framework/pull/36201)) +- Make `sole()` relationship friendly ([#36200](https://github.com/laravel/framework/pull/36200)) +- Make layout in mail responsive in Gmail app ([#36198](https://github.com/laravel/framework/pull/36198)) +- Fixes parallel testing when a database is configured using URLs ([#36204](https://github.com/laravel/framework/pull/36204)) + + +## [v8.26.1 (2021-02-02)](https://github.com/laravel/framework/compare/v8.26.0...v8.26.1) + +### Fixed +- Fixed merge conflict in `src/Illuminate/Foundation/Console/stubs/exception-render-report.stub` ([#36123](https://github.com/laravel/framework/pull/36123)) + + +## [v8.26.0 (2021-02-02)](https://github.com/laravel/framework/compare/v8.25.0...v8.26.0) + +### Added +- Allow to fillJsonAttribute with encrypted field ([#36063](https://github.com/laravel/framework/pull/36063)) +- Added `Route::missing()` ([#36035](https://github.com/laravel/framework/pull/36035)) +- Added `Illuminate\Support\Str::markdown()` and `Illuminate\Support\Stringable::markdown()` ([#36071](https://github.com/laravel/framework/pull/36071)) +- Support retrieving URL for Sftp adapter ([#36120](https://github.com/laravel/framework/pull/36120)) + +### Fixed +- Fixed issues with dumping PostgreSQL databases that contain multiple schemata ([#36046](https://github.com/laravel/framework/pull/36046)) +- Fixes job batch serialization for PostgreSQL ([#36081](https://github.com/laravel/framework/pull/36081)) +- Fixed `Illuminate\View\ViewException::report()` ([#36110](https://github.com/laravel/framework/pull/36110)) + +### Changed +- Typecast page number as integer in `Illuminate\Pagination\AbstractPaginator::resolveCurrentPage()` ([#36055](https://github.com/laravel/framework/pull/36055)) +- Changed `Illuminate\Testing\ParallelRunner::createApplication()` ([1c11b78](https://github.com/laravel/framework/commit/1c11b7893fa3e9c592f6e85b2b1b0028ddd55645)) + + +## [v8.25.0 (2021-01-26)](https://github.com/laravel/framework/compare/v8.24.0...v8.25.0) + +### Added +- Added `Stringable::pipe` & make Stringable tappable ([#36017](https://github.com/laravel/framework/pull/36017)) +- Accept a command in object form in Bus::assertChained ([#36031](https://github.com/laravel/framework/pull/36031)) +- Adds parallel testing ([#36034](https://github.com/laravel/framework/pull/36034)) +- Make Listeners, Mailables, and Notifications accept ShouldBeEncrypted ([#36036](https://github.com/laravel/framework/pull/36036)) +- Support JSON encoding Stringable ([#36012](https://github.com/laravel/framework/pull/36012)) +- Support for escaping bound attributes ([#36042](https://github.com/laravel/framework/pull/36042)) +- Added `Illuminate\Foundation\Application::useLangPath()` ([#36044](https://github.com/laravel/framework/pull/36044)) + +### Changed +- Pipe through new render and report exception methods ([#36032](https://github.com/laravel/framework/pull/36032)) + +### Fixed +- Fixed issue with dumping schema from a postgres database using no default schema ([#35966](https://github.com/laravel/framework/pull/35966), [7be50a5](https://github.com/laravel/framework/commit/7be50a511955dea2bf4d6e30208b6fbf07eaa36e)) +- Fixed worker --delay option ([#35991](https://github.com/laravel/framework/pull/35991)) +- Added support of PHP 7.3 to RateLimiter middleware(queue) serialization ([#35986](https://github.com/laravel/framework/pull/35986)) +- Fixed `Illuminate\Foundation\Http\Middleware\TransformsRequest::cleanArray()` ([#36002](https://github.com/laravel/framework/pull/36002)) +- ModelNotFoundException: ensure that the model class name is properly set ([#36011](https://github.com/laravel/framework/pull/36011)) +- Fixed bus fake ([e720279](https://github.com/laravel/framework/commit/e72027960fd4d8ff281938edb4632e13e391b8fd)) + + +## [v8.24.0 (2021-01-21)](https://github.com/laravel/framework/compare/v8.23.1...v8.24.0) + +### Added +- Added `JobQueued` event ([8eaec03](https://github.com/laravel/framework/commit/8eaec037421aa9f3860da9d339986448b4c884eb), [5d572e7](https://github.com/laravel/framework/commit/5d572e7a6d479ef68ee92c9d67e2e9465174fb4c)) + +### Fixed +- Fixed type error in `Illuminate\Http\Concerns\InteractsWithContentTypes::isJson()` ([#35956](https://github.com/laravel/framework/pull/35956)) +- Fixed `Illuminate\Collections\Collection::sortByMany()` ([#35950](https://github.com/laravel/framework/pull/35950)) +- Fixed Limit expected bindings ([#35972](https://github.com/laravel/framework/pull/35972), [006873d](https://github.com/laravel/framework/commit/006873df411d28bfd03fea5e7f91a2afe3918498)) +- Fixed serialization of rate limited with redis middleware ([#35971](https://github.com/laravel/framework/pull/35971)) + + +## [v8.23.1 (2021-01-19)](https://github.com/laravel/framework/compare/v8.23.0...v8.23.1) + +### Fixed +- Fixed empty html mail ([#35941](https://github.com/laravel/framework/pull/35941)) + + +## [v8.23.0 (2021-01-19)](https://github.com/laravel/framework/compare/v8.22.1...v8.23.0) + +### Added +- Added `Illuminate\Database\Concerns\BuildsQueries::sole()` ([#35869](https://github.com/laravel/framework/pull/35869), [29c7dae](https://github.com/laravel/framework/commit/29c7dae9b32af2abffa7489f4758fd67905683c3), [#35908](https://github.com/laravel/framework/pull/35908), [#35902](https://github.com/laravel/framework/pull/35902), [#35912](https://github.com/laravel/framework/pull/35912)) +- Added default parameter to throw_if / throw_unless ([#35890](https://github.com/laravel/framework/pull/35890)) +- Added validation support for TeamSpeak3 URI scheme ([#35933](https://github.com/laravel/framework/pull/35933)) + +### Fixed +- Fixed extra space on blade class components that are inline ([#35874](https://github.com/laravel/framework/pull/35874)) +- Fixed serialization of rate limited middleware ([f3d4dcb](https://github.com/laravel/framework/commit/f3d4dcb21dc66824611fdde95c8075b694825bf5), [#35916](https://github.com/laravel/framework/pull/35916)) + +### Changed +- Allow a specific seeder to be used in tests in `Illuminate\Foundation\Testing\RefreshDatabase::migrateFreshUsing()` ([#35864](https://github.com/laravel/framework/pull/35864)) +- Pass $key to closure in Collection and LazyCollection's reduce method as well ([#35878](https://github.com/laravel/framework/pull/35878)) + + +## [v8.22.1 (2021-01-13)](https://github.com/laravel/framework/compare/v8.22.0...v8.22.1) + +### Fixed +- Limit expected bindings ([#35865](https://github.com/laravel/framework/pull/35865)) + + +## [v8.22.0 (2021-01-12)](https://github.com/laravel/framework/compare/v8.21.0...v8.22.0) + +### Added +- Added new lines to `DetectsLostConnections` ([#35752](https://github.com/laravel/framework/pull/35752), [#35790](https://github.com/laravel/framework/pull/35790)) +- Added `Illuminate\Support\Testing\Fakes\EventFake::assertNothingDispatched()` ([#35835](https://github.com/laravel/framework/pull/35835)) +- Added reduce with keys to collections and lazy collections ([#35839](https://github.com/laravel/framework/pull/35839)) + +### Fixed +- Fixed error from missing null check on PHP 8 in `Illuminate\Validation\Concerns\ValidatesAttributes::validateJson()` ([#35797](https://github.com/laravel/framework/pull/35797)) +- Fix bug with RetryCommand ([4415b94](https://github.com/laravel/framework/commit/4415b94623358bfd1dc2e8f20e4deab0025d2d03), [#35828](https://github.com/laravel/framework/pull/35828)) +- Fixed `Illuminate\Testing\PendingCommand::expectsTable()` ([#35820](https://github.com/laravel/framework/pull/35820)) +- Fixed `morphTo()` attempting to map an empty string morph type to an instance ([#35824](https://github.com/laravel/framework/pull/35824)) + +### Changes +- Update `Illuminate\Http\Resources\CollectsResources::collects()` ([1fa20dd](https://github.com/laravel/framework/commit/1fa20dd356af21af6e38d95e9ff2b1d444344fbe)) +- "null" constraint prevents aliasing SQLite ROWID ([#35792](https://github.com/laravel/framework/pull/35792)) +- Allow strings to be passed to the `report` function ([#35803](https://github.com/laravel/framework/pull/35803)) + + +## [v8.21.0 (2021-01-05)](https://github.com/laravel/framework/compare/v8.20.1...v8.21.0) + +### Added +- Added command to clean batches table ([#35694](https://github.com/laravel/framework/pull/35694), [33f5ac6](https://github.com/laravel/framework/commit/33f5ac695a55d6cdbadcfe1b46e3409e4a66df16)) +- Added item to list of causedByLostConnection errors ([#35744](https://github.com/laravel/framework/pull/35744)) +- Make it possible to set Postmark Message Stream ID ([#35755](https://github.com/laravel/framework/pull/35755)) + +### Fixed +- Fixed `php artisan db` command for the Postgres CLI ([#35725](https://github.com/laravel/framework/pull/35725)) +- Fixed OPTIONS method bug with use same path and diff domain when cache route ([#35714](https://github.com/laravel/framework/pull/35714)) + +### Changed +- Ensure DBAL custom type doesn't exists in `Illuminate\Database\DatabaseServiceProvider::registerDoctrineTypes()` ([#35704](https://github.com/laravel/framework/pull/35704)) +- Added missing `dispatchAfterCommit` to `DatabaseQueue` ([#35715](https://github.com/laravel/framework/pull/35715)) +- Set chain queue when inside a batch ([#35746](https://github.com/laravel/framework/pull/35746)) +- Give a more meaningul message when route parameters are missing ([#35706](https://github.com/laravel/framework/pull/35706)) +- Added table prefix to `Illuminate\Database\Console\DumpCommand::schemaState()` ([4ffe40f](https://github.com/laravel/framework/commit/4ffe40fb169c6bcce9193ff56958eca41e64294f)) +- Refresh the retryUntil time on job retry ([#35780](https://github.com/laravel/framework/pull/35780), [45eb7a7](https://github.com/laravel/framework/commit/45eb7a7b1706ae175268731a673f369c0e556805)) + + +## [v8.20.1 (2020-12-22)](https://github.com/laravel/framework/compare/v8.20.0...v8.20.1) + +### Revert +- Revert [Clear a cached user in RequestGuard if a request is changed](https://github.com/laravel/framework/pull/35692) ([ca8ccd6](https://github.com/laravel/framework/commit/ca8ccd6757d5639f0e5fb241b3df6878da6ce34e)) + + +## [v8.20.0 (2020-12-22)](https://github.com/laravel/framework/compare/v8.19.0...v8.20.0) + +### Added +- Added `Illuminate\Database\DBAL\TimestampType` ([a5761d4](https://github.com/laravel/framework/commit/a5761d4187abea654cb422c2f70054a880ffd2e0), [cff3705](https://github.com/laravel/framework/commit/cff37055cbf031109ae769e8fd6ad1951be47aa6) [382445f](https://github.com/laravel/framework/commit/382445f8487de45a05ebe121837f917b92560a97), [810047e](https://github.com/laravel/framework/commit/810047e1f184f8a4def372885591e4fbb6996b51)) +- Added ability to specify a separate lock connection ([#35621](https://github.com/laravel/framework/pull/35621), [3d95235](https://github.com/laravel/framework/commit/3d95235a6ad8525886071ad68e818a225786064f)) +- Added `Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable::syncWithPivotValues()` ([#35644](https://github.com/laravel/framework/pull/35644), [49b3ce0](https://github.com/laravel/framework/commit/49b3ce098d8a612797b195c4e3774b1e00c604c8)) + +### Fixed +- Fixed `Illuminate\Validation\Concerns\ValidatesAttributes::validateJson()` for PHP8 ([#35646](https://github.com/laravel/framework/pull/35646)) +- Fixed `assertCookieExpired()` and `assertCookieNotExpired()` methods in `Illuminate\Testing\TestResponse` ([#35637](https://github.com/laravel/framework/pull/35637)) +- Fixed: Account for a numerical array of views in Mailable::renderForAssertions() ([#35662](https://github.com/laravel/framework/pull/35662)) +- Catch DecryptException with invalid X-XSRF-TOKEN in `Illuminate\Foundation\Http\Middleware\VerifyCsrfToken` ([#35671](https://github.com/laravel/framework/pull/35671)) + +### Changed +- Check configuration in `Illuminate\Foundation\Console\Kernel::scheduleCache()` ([a253d0e](https://github.com/laravel/framework/commit/a253d0e40d3deb293d54df9f4455879af5365aab)) +- Modify `Model::mergeCasts` to return `$this` ([#35683](https://github.com/laravel/framework/pull/35683)) +- Clear a cached user in RequestGuard if a request is changed ([#35692](https://github.com/laravel/framework/pull/35692)) + + +## [v8.19.0 (2020-12-15)](https://github.com/laravel/framework/compare/v8.18.1...v8.19.0) + +### Added +- Delay pushing jobs to queue until database transactions are committed ([#35422](https://github.com/laravel/framework/pull/35422), [095d922](https://github.com/laravel/framework/commit/095d9221e837e7a7d8bd00d14184e619b962173a), [fa34d93](https://github.com/laravel/framework/commit/fa34d93ad0cda78e8956b2680ba7bada02bcabb2), [db0d0ba](https://github.com/laravel/framework/commit/db0d0ba94cf8a5c1e1fa4621bd4a16032aff800d), [d9b803a](https://github.com/laravel/framework/commit/d9b803a1a4898e7d5b3145e51c77499815ce3401), [3e55841](https://github.com/laravel/framework/commit/3e5584165fb66d8228bae79a856eac51ce147df5)) +- Added `Illuminate\View\ComponentAttributeBag::has()` ([#35562](https://github.com/laravel/framework/pull/35562)) +- Create ScheduleListCommand ([#35574](https://github.com/laravel/framework/pull/35574), [97d7834](https://github.com/laravel/framework/commit/97d783449c5330b1e5fb9104f6073869ad3079c1)) +- Introducing Job Encryption ([#35527](https://github.com/laravel/framework/pull/35527), [f80f647](https://github.com/laravel/framework/commit/f80f647852106942e4a0ef3e9963f8f7a99122cf), [8c16156](https://github.com/laravel/framework/commit/8c16156636311e42883d9e84a6d71fa135bc2b73)) + +### Fixed +- Handle `Throwable` exceptions on `Illuminate\Redis\Limiters\ConcurrencyLimiter::block()` ([#35546](https://github.com/laravel/framework/pull/35546)) +- Fixed PDO passing in SqlServerDriver ([#35564](https://github.com/laravel/framework/pull/35564)) +- When following redirects, terminate each test request in proper order ([#35604](https://github.com/laravel/framework/pull/35604)) + + +## [v8.18.1 (2020-12-09)](https://github.com/laravel/framework/compare/v8.18.0...v8.18.1) + +### Fixed +- Bumped minimum Symfony version ([#35535](https://github.com/laravel/framework/pull/35535)) +- Fixed passing model instances to factories ([#35541](https://github.com/laravel/framework/pull/35541)) + + +## [v8.18.0 (2020-12-08)](https://github.com/laravel/framework/compare/v8.17.2...v8.18.0) + +### Added +- Added `Illuminate\Http\Client\Factory::assertSentInOrder()` ([#35525](https://github.com/laravel/framework/pull/35525), [d257ce2](https://github.com/laravel/framework/commit/d257ce2e93dfe52151be3d0386fcc4ea281ca8d5), [2fd1411](https://github.com/laravel/framework/commit/2fd141158eb5aead8aa2afff51bcd98250b6bbe6)) +- Added `Illuminate\Http\Client\Response::handlerStats()` ([#35520](https://github.com/laravel/framework/pull/35520)) +- Added support for attaching existing model instances in factories ([#35494](https://github.com/laravel/framework/pull/35494)) +- Added `assertChained()` and `assertDispatchedWithoutChain()` methods to `Illuminate\Support\Testing\Fakes\BusFake` class ([#35523](https://github.com/laravel/framework/pull/35523), [f1b8cac](https://github.com/laravel/framework/commit/f1b8cacfe2a8863894e258ce35a77decedbea36f), [236c67d](https://github.com/laravel/framework/commit/236c67db52f755bb475ba325148e9053733968aa)) +- Allow testing of html and plain text bodies right off mailables ([afb858a](https://github.com/laravel/framework/commit/afb858ad9c944bd3f9ad56c3e4485527d77a7327), [b7391e4](https://github.com/laravel/framework/commit/b7391e486fc68c1c422668a277eaac2bcbe72b2b)) + +### Fixed +- Fixed Application flush method ([#35482](https://github.com/laravel/framework/pull/35482)) +- Fixed mime validation for jpeg files ([#35518](https://github.com/laravel/framework/pull/35518)) + +### Revert +- Revert [Added ability to define table name as default morph type](https://github.com/laravel/framework/pull/35257) ([#35533](https://github.com/laravel/framework/pull/35533)) + + +## [v8.17.2 (2020-12-03)](https://github.com/laravel/framework/compare/v8.17.1...v8.17.2) + +### Added +- Added `Illuminate\Database\Eloquent\Relations\BelongsToMany::orderByPivot()` ([#35455](https://github.com/laravel/framework/pull/35455), [6f83a50](https://github.com/laravel/framework/commit/6f83a5099725dc47fbec1b0cf1bcc64f80f9dc86)) + + +## [v8.17.1 (2020-12-02)](https://github.com/laravel/framework/compare/v8.17.0...v8.17.1) + +### Fixed +- Fixed an issue with the database queue driver ([#35449](https://github.com/laravel/framework/pull/35449)) + + +## [v8.17.0 (2020-12-01)](https://github.com/laravel/framework/compare/v8.16.1...v8.17.0) + +### Added +- Added: Transaction aware code execution ([#35373](https://github.com/laravel/framework/pull/35373), [9565598](https://github.com/laravel/framework/commit/95655988ea1fb0c260ca792751e2e9da81afc3a7)) +- Added dd() and dump() to the request object ([#35384](https://github.com/laravel/framework/pull/35384), [c43e08f](https://github.com/laravel/framework/commit/c43e08f98afe5dcf742956510e9ab170ea11ce45)) +- Enqueue all jobs using a enqueueUsing method ([#35415](https://github.com/laravel/framework/pull/35415), [010d4d7](https://github.com/laravel/framework/commit/010d4d7ea7ec5581dfbf8b6ba84b812f8e4cb649), [#35437](https://github.com/laravel/framework/pull/35437)) + +### Fixed +- Fix issue with polymorphic morphMaps with literal 0 ([#35364](https://github.com/laravel/framework/pull/35364)) +- Fixed Self-Relation issue in withAggregate method ([#35392](https://github.com/laravel/framework/pull/35392), [aec5cca](https://github.com/laravel/framework/commit/aec5cca4ace65bc4b4ca054170b645f1073ac9ca), [#35394](https://github.com/laravel/framework/pull/35394)) +- Fixed Use PHP_EOL instead of `\n` in PendingCommand ([#35409](https://github.com/laravel/framework/pull/35409)) +- Fixed validating image/jpeg images after Symfony/Mime update ([#35419](https://github.com/laravel/framework/pull/35419)) +- Fixed fail to morph with custom casting to objects ([#35420](https://github.com/laravel/framework/pull/35420)) +- Fixed `Illuminate\Collections\Collection::sortBy()` ([307f6fb](https://github.com/laravel/framework/commit/307f6fb8d9579427a9521a07e8700355a3e9d948)) +- Don't overwrite minute and hour when specifying a time with twiceMonthly() ([#35436](https://github.com/laravel/framework/pull/35436)) + +### Changed +- Make DownCommand retryAfter available to prerendered view ([#35357](https://github.com/laravel/framework/pull/35357), [b1ee97e](https://github.com/laravel/framework/commit/b1ee97e5ae03dae293e3256b8c3013209d0fd9b0)) +- Set default value on cloud driver ([0bb7fe4](https://github.com/laravel/framework/commit/0bb7fe4758d617b07b84f6fabfcfe2ca2cdb0964)) +- Update Tailwind pagination focus styles ([#35365](https://github.com/laravel/framework/pull/35365)) +- Redis: allow to pass connection name ([#35402](https://github.com/laravel/framework/pull/35402)) +- Change Wormhole to use the Date Factory ([#35421](https://github.com/laravel/framework/pull/35421)) + + +## [v8.16.1 (2020-11-25)](https://github.com/laravel/framework/compare/v8.16.0...v8.16.1) + +### Fixed +- Fixed reflection exception in `Illuminate\Routing\Router::gatherRouteMiddleware()` ([c6e8357](https://github.com/laravel/framework/commit/c6e8357e19b10a800df8a67446f23310f4e83d1f)) + + +## [v8.16.0 (2020-11-24)](https://github.com/laravel/framework/compare/v8.15.0...v8.16.0) + +### Added +- Added `Illuminate\Console\Concerns\InteractsWithIO::withProgressBar()` ([4e52a60](https://github.com/laravel/framework/commit/4e52a606e91619f6082ed8d46f8d64f9d4dbd0b2), [169fd2b](https://github.com/laravel/framework/commit/169fd2b5156650a067aa77a38681875d2a6c5e57)) +- Added `Illuminate\Console\Concerns\CallsCommands::callSilently()` as alias for `callSilent()` ([7f3101b](https://github.com/laravel/framework/commit/7f3101bf6e8a0f048a243a55be7fc79eb359b609), [0294433](https://github.com/laravel/framework/commit/029443349294e3b6e7bebfe9c23a51a9821ec497)) +- Added option to release unique job locks before processing ([#35255](https://github.com/laravel/framework/pull/35255), [b53f13e](https://github.com/laravel/framework/commit/b53f13ef6c8625176defcb83d2fb8d4d5887d068)) +- Added ably broadcaster ([e0f3f8e](https://github.com/laravel/framework/commit/e0f3f8e8241e1ea34a3a3b8c543871cdc00290bf), [6381aa9](https://github.com/laravel/framework/commit/6381aa994756429156b7376e98606458b052b1d7)) +- Added ability to define table name as default morph type ([#35257](https://github.com/laravel/framework/pull/35257)) +- Allow overriding the MySQL server version for database queue driver ([#35263](https://github.com/laravel/framework/pull/35263)) +- Added `Illuminate\Foundation\Testing\Wormhole::back()` ([#35261](https://github.com/laravel/framework/pull/35261)) +- Support delaying notifications per channel ([#35273](https://github.com/laravel/framework/pull/35273)) +- Allow sorting on multiple criteria ([#35277](https://github.com/laravel/framework/pull/35277), [53eb307](https://github.com/laravel/framework/commit/53eb307fea077299d409adf3ba0307a8fda4c4d1)) +- Added `Illuminate/Database/Console/DbCommand.php` command ([#35304](https://github.com/laravel/framework/pull/35304), [b559b3e](https://github.com/laravel/framework/commit/b559b3e7c4995ef468b35e8a6117ef24fdeca053)) +- Added Collections `splitIn` methods ([#35295](https://github.com/laravel/framework/pull/35295)) + +### Fixed +- Fixed rendering of notifications with config custom theme ([325a335](https://github.com/laravel/framework/commit/325a335ccf45426eabb27131ed48aa6114434c99)) +- Fixing BroadcastException message in PusherBroadcaster@broadcast ([#35290](https://github.com/laravel/framework/pull/35290)) +- Fixed generic DetectsLostConnection string ([#35323](https://github.com/laravel/framework/pull/35323)) +- Fixed SQL Server command generation ([#35317](https://github.com/laravel/framework/pull/35317)) +- Fixed route model binding on cached closure routes ([eb3e262](https://github.com/laravel/framework/commit/eb3e262c870739a6e9705b851e0066b3473eed2b)) + +### Changed +- Disable CSRF on broadcast route ([acb4b77](https://github.com/laravel/framework/commit/acb4b77adc6e257e132e3b036abe1ec88885cfb7)) +- Easily set a null cache driver ([#35262](https://github.com/laravel/framework/pull/35262)) +- Updated `aws/aws-sdk-php` suggest to `^3.155` ([#35267](https://github.com/laravel/framework/pull/35267)) +- Ensure ShouldBeUniqueUntilProcessing job lock is released once ([#35270](https://github.com/laravel/framework/pull/35270)) +- Rename qualifyColumn to qualifyPivotColumn in BelongsToMany & MorphToMany ([#35276](https://github.com/laravel/framework/pull/35276)) +- Check if AsPivot trait is used instead of Pivot Model in `Illuminate\Database\Eloquent\Relations\BelongsToMany` ([#35271](https://github.com/laravel/framework/pull/35271)) +- Avoid no-op database query in Model::destroy() with empty ids ([#35294](https://github.com/laravel/framework/pull/35294)) +- Use --no-owner and --no-acl with pg_restore ([#35309](https://github.com/laravel/framework/pull/35309)) + + +## [v8.15.0 (2020-11-17)](https://github.com/laravel/framework/compare/v8.14.0...v8.15.0) + +### Added +- Added lock support for file and null cache drivers ([#35139](https://github.com/laravel/framework/pull/35139), [a345185](https://github.com/laravel/framework/commit/a3451859d1cff45fba423cf577d00f5b2b648c7a)) +- Added a `doesntExpectOutput` method for console command testing ([#35160](https://github.com/laravel/framework/pull/35160), [c90fc5f](https://github.com/laravel/framework/commit/c90fc5f6b8e91e3f6b0f2f3a74cad7d8a49bc71b)) +- Added support of MorphTo relationship eager loading constraints ([#35190](https://github.com/laravel/framework/pull/35190)) +- Added `Illuminate\Http\ResponseTrait::withoutCookie()` ([e9483c4](https://github.com/laravel/framework/commit/e9483c441d5f0c8598d438d6024db8b1a7aa55fe)) +- Use dynamic app namespace in Eloquent Factory instead of App\ string ([#35204](https://github.com/laravel/framework/pull/35204), [4885bd2](https://github.com/laravel/framework/commit/4885bd2d4ecf79de175d5308569ab0d608e8f55b)) +- Added `read` / `unread` scopes to database notifications ([#35215](https://github.com/laravel/framework/pull/35215)) +- Added `classBasename()` method to `Stringable` ([#35219](https://github.com/laravel/framework/pull/35219)) +- Added before resolving callbacks to container ([#35228](https://github.com/laravel/framework/pull/35228)) +- Adds the possibility of testing file upload content ([#35231](https://github.com/laravel/framework/pull/35231)) +- Added lost connection messages for MySQL persistent connections ([#35224](https://github.com/laravel/framework/pull/35224)) +- Added Support DBAL v3.0 ([#35236](https://github.com/laravel/framework/pull/35236)) + +### Fixed +- Update MySqlSchemaState.php to support MariaDB dump ([#35184](https://github.com/laravel/framework/pull/35184)) +- Fixed pivot and morphpivot fresh and refresh methods ([#35193](https://github.com/laravel/framework/pull/35193)) +- Fixed pivot restoration ([#35218](https://github.com/laravel/framework/pull/35218)) + +### Changed +- Updated `EmailVerificationRequest.php` to check if user is not already verified ([#35174](https://github.com/laravel/framework/pull/35174)) +- Make `Validator::parseNamedParameters()` public ([#35183](https://github.com/laravel/framework/pull/35183)) +- Ignore max attempts if retryUntil is set in `queue:work` ([#35214](https://github.com/laravel/framework/pull/35214)) +- Explode string channels on `Illuminate/Log/LogManager::createStackDriver()` ([e5b86f2](https://github.com/laravel/framework/commit/e5b86f2efec2959fb0e85ad5ee5de18f430643c4)) + + +## [v8.14.0 (2020-11-10)](https://github.com/laravel/framework/compare/v8.13.0...v8.14.0) + +### Added +- Added ability to dispatch unique jobs ([#35042](https://github.com/laravel/framework/pull/35042), [2123e60](https://github.com/laravel/framework/commit/2123e603af027e7590974864715c028357ea4969)) +- Added `Model::encryptUsing()` ([#35080](https://github.com/laravel/framework/pull/35080)) +- Added support to MySQL dump and import using socket ([#35083](https://github.com/laravel/framework/pull/35083), [c43054b](https://github.com/laravel/framework/commit/c43054b9decad4f66937c229e4ef0f32760c8611)) +- Allow custom broadcastWith in notification broadcast channel ([#35142](https://github.com/laravel/framework/pull/35142)) +- Added `Illuminate\Routing\CreatesRegularExpressionRouteConstraints::whereAlphaNumeric()` ([#35154](https://github.com/laravel/framework/pull/35154)) + +### Fixed +- Fixed typo in `make:seeder` command name inside ModelMakeCommand ([#35107](https://github.com/laravel/framework/pull/35107)) +- Fix SQL Server grammar for upsert (missing semicolon) ([#35112](https://github.com/laravel/framework/pull/35112)) +- Respect migration table name in config when dumping schema ([110eb15](https://github.com/laravel/framework/commit/110eb15a77f84da0d83ebc2bb123eec08ecc19ca)) +- Respect theme when previewing notification ([ed4411d](https://github.com/laravel/framework/commit/ed4411d310f259f75e95e882b748ba9d76d7cfad)) +- Fix appendable attributes in Blade components ([#35131](https://github.com/laravel/framework/pull/35131)) +- Remove decrypting array cookies from cookie decrypting ([#35130](https://github.com/laravel/framework/pull/35130)) +- Turn the eloquent collection into a base collection if mapWithKeys loses models ([#35129](https://github.com/laravel/framework/pull/35129)) + +### Changed +- Move dispatching of DatabaseRefreshed event to fire before seeders are run ([#35091](https://github.com/laravel/framework/pull/35091)) +- Handle returning false from reportable callback ([55f0b5e](https://github.com/laravel/framework/commit/55f0b5e7449b87b7340a761bf9e6456fdc8ffc4d)) +- Update `Illuminate\Database\Schema\Grammars\MySqlGrammar::typeTimestamp()` ([#35143](https://github.com/laravel/framework/pull/35143)) +- Remove expectedTables after converting to expectedOutput in PendingCommand ([#35163](https://github.com/laravel/framework/pull/35163)) +- Change SQLite schema command environment variables to work on Windows ([#35164](https://github.com/laravel/framework/pull/35164)) + + +## [v8.13.0 (2020-11-03)](https://github.com/laravel/framework/compare/v8.12.3...v8.13.0) + +### Added +- Added `loadMax()` | `loadMin()` | `loadSum()` | `loadAvg()` methods to `Illuminate\Database\Eloquent\Collection`. Added `loadMax()` | `loadMin()` | `loadSum()` | `loadAvg()` | `loadMorphMax()` | `loadMorphMin()` | `loadMorphSum()` | `loadMorphAvg()` methods to `Illuminate\Database\Eloquent\Model` ([#35029](https://github.com/laravel/framework/pull/35029)) +- Modify `Illuminate\Database\Eloquent\Concerns\QueriesRelationships::has()` method to support MorphTo relations ([#35050](https://github.com/laravel/framework/pull/35050)) +- Added `Illuminate\Support\Stringable::chunk()` ([#35038](https://github.com/laravel/framework/pull/35038)) + +### Fixed +- Fixed a few issues in `Illuminate\Database\Eloquent\Concerns\QueriesRelationships::withAggregate()` ([#35061](https://github.com/laravel/framework/pull/35061), [#35063](https://github.com/laravel/framework/pull/35063)) + +### Changed +- Set chain `queue` | `connection` | `delay` only when explicitly configured in ([#35047](https://github.com/laravel/framework/pull/35047)) + +### Refactoring +- Remove redundant unreachable return statements in some places ([#35053](https://github.com/laravel/framework/pull/35053)) + + +## [v8.12.3 (2020-10-30)](https://github.com/laravel/framework/compare/v8.12.2...v8.12.3) + +### Fixed +- Fixed `Illuminate\Database\Eloquent\Concerns\QueriesRelationships::withAggregate()` ([20b0c6e](https://github.com/laravel/framework/commit/20b0c6e19b635466f776502b3f1260c7c51b04ae)) + + +## [v8.12.2 (2020-10-29)](https://github.com/laravel/framework/compare/v8.12.1...v8.12.2) + +### Fixed +- [Add some fixes](https://github.com/laravel/framework/compare/v8.12.1...v8.12.2) + + +## [v8.12.1 (2020-10-29)](https://github.com/laravel/framework/compare/v8.12.0...v8.12.1) + +### Fixed +- Fixed alias usage in `Eloquent` ([6091048](https://github.com/laravel/framework/commit/609104806b8b639710268c75c22f43034c2b72db)) +- Fixed `Illuminate\Support\Reflector::isCallable()` ([a90f344](https://github.com/laravel/framework/commit/a90f344c66f0a5bb1d718f8bbd20c257d4de9e02)) + + +## [v8.12.0 (2020-10-29)](https://github.com/laravel/framework/compare/v8.11.2...v8.12.0) + +### Added +- Added ability to create observers with custom path via `make:observer` command ([#34911](https://github.com/laravel/framework/pull/34911)) +- Added `Illuminate\Database\Eloquent\Factories\Factory::lazy()` ([#34923](https://github.com/laravel/framework/pull/34923)) +- Added ability to make cast with custom stub file via `make:cast` command ([#34930](https://github.com/laravel/framework/pull/34930)) +- ADDED: Custom casts can implement increment/decrement logic ([#34964](https://github.com/laravel/framework/pull/34964)) +- Added encrypted Eloquent cast ([#34937](https://github.com/laravel/framework/pull/34937), [#34948](https://github.com/laravel/framework/pull/34948)) +- Added `DatabaseRefreshed` event to be emitted after database refreshed ([#34952](https://github.com/laravel/framework/pull/34952), [f31bfe2](https://github.com/laravel/framework/commit/f31bfe2fb83829a900f75fccd12af4b69ffb6275)) +- Added `withMax()`|`withMin()`|`withSum()`|`withAvg()` methods to `Illuminate/Database/Eloquent/Concerns/QueriesRelationships` ([#34965](https://github.com/laravel/framework/pull/34965), [f4e4d95](https://github.com/laravel/framework/commit/f4e4d95c8d4c2f63f9bd80c2a4cfa6b2c78bab1b), [#35004](https://github.com/laravel/framework/pull/35004)) +- Added `explain()` to `Query\Builder` and `Eloquent\Builder` ([#34969](https://github.com/laravel/framework/pull/34969)) +- Make `multiple_of` validation rule handle non-integer values ([#34971](https://github.com/laravel/framework/pull/34971)) +- Added `setKeysForSelectQuery` method and use it when refreshing model data in Models ([#34974](https://github.com/laravel/framework/pull/34974)) +- Full PHP 8.0 Support ([#33388](https://github.com/laravel/framework/pull/33388)) +- Added `Illuminate\Support\Reflector::isCallable()` ([#34994](https://github.com/laravel/framework/pull/34994), [8c16891](https://github.com/laravel/framework/commit/8c16891c6e7a4738d63788f4447614056ab5136e), [31917ab](https://github.com/laravel/framework/commit/31917abcfa0db6ec6221bb07fc91b6e768ff5ec8), [11cfa4d](https://github.com/laravel/framework/commit/11cfa4d4c92bf2f023544d58d51b35c5d31dece0), [#34999](https://github.com/laravel/framework/pull/34999)) +- Added route regex registration methods ([#34997](https://github.com/laravel/framework/pull/34997), [3d405cc](https://github.com/laravel/framework/commit/3d405cc2eb66bba97433b46abaca52623c64c94b), [c2df0d5](https://github.com/laravel/framework/commit/c2df0d5faddeb7e58d1832c1c1f0f309619969af)) +- Added dontRelease option to RateLimited and RateLimitedWithRedis job middleware ([#35010](https://github.com/laravel/framework/pull/35010)) + +### Fixed +- Fixed check of file path in `Illuminate\Database\Schema\PostgresSchemaState::load()` ([268237f](https://github.com/laravel/framework/commit/268237fcda420e5c26ab2f0fbdb9b8783c276ff8)) +- Fixed: `PhpRedis (v5.3.2)` cluster - set default connection context to `null` ([#34935](https://github.com/laravel/framework/pull/34935)) +- Fixed Eloquent Model `loadMorph` and `loadMorphCount` methods ([#34972](https://github.com/laravel/framework/pull/34972)) +- Fixed ambigious column on many to many with select load ([5007986](https://github.com/laravel/framework/commit/500798623d100a9746b2931ae6191cb756521f05)) +- Fixed Postgres Dump ([#35018](https://github.com/laravel/framework/pull/35018)) + +### Changed +- Changed `make:factory` command ([#34947](https://github.com/laravel/framework/pull/34947), [4f38176](https://github.com/laravel/framework/commit/4f3817654a6376a2f6cd59dc5fb529ebad1d951f)) +- Make assertSee, assertSeeText, assertDontSee and assertDontSeeText accept an array ([#34982](https://github.com/laravel/framework/pull/34982), [2b98bcc](https://github.com/laravel/framework/commit/2b98bcca598eb919b2afd61e5fb5cb86aec4c706)) + + +## [v8.11.2 (2020-10-20)](https://github.com/laravel/framework/compare/v8.11.1...v8.11.2) + +### Revert +- Revert ["Change loadRoutesFrom to accept $attributes](https://github.com/laravel/framework/pull/34866)" ([#34909](https://github.com/laravel/framework/pull/34909)) + + +## [v8.11.1 (2020-10-20)](https://github.com/laravel/framework/compare/v8.11.0...v8.11.1) + +### Fixed +- Fixed `bound()` method ([a7759d7](https://github.com/laravel/framework/commit/a7759d70e15b0be946569b8299ac694c08a35d7e)) + + +## [v8.11.0 (2020-10-20)](https://github.com/laravel/framework/compare/v8.10.0...v8.11.0) + +### Added +- Added job middleware to prevent overlapping jobs ([#34794](https://github.com/laravel/framework/pull/34794), [eed05b4](https://github.com/laravel/framework/commit/eed05b41097cfe62766d4086ede8dee97c057c29)) +- Bring Rate Limiters to Jobs ([#34829](https://github.com/laravel/framework/pull/34829), [ae00294](https://github.com/laravel/framework/commit/ae00294c418e431372bad0d09ac15d15925247f7)) +- Added `multiple_of` custom replacer in validator ([#34858](https://github.com/laravel/framework/pull/34858)) +- Preserve eloquent collection type after calling ->fresh() ([#34848](https://github.com/laravel/framework/pull/34848)) +- Provisional support for PHP 8.0 for 6.x (Changed some code in 8.x) ([#34884](https://github.com/laravel/framework/pull/34884), [28bb76e](https://github.com/laravel/framework/commit/28bb76efbcfc5fee57307ffa062b67ff709240dc)) + +### Fixed +- Fixed `fresh()` and `refresh()` on pivots and morph pivots ([#34836](https://github.com/laravel/framework/pull/34836)) +- Fixed config `batching` typo ([#34852](https://github.com/laravel/framework/pull/34852)) +- Fixed `Illuminate\Queue\Console\RetryBatchCommand` for un-found batch id ([#34878](https://github.com/laravel/framework/pull/34878)) + +### Changed +- Change `loadRoutesFrom()` to accept group $attributes ([#34866](https://github.com/laravel/framework/pull/34866)) + + +## [v8.10.0 (2020-10-13)](https://github.com/laravel/framework/compare/v8.9.0...v8.10.0) + +### Added +- Allow for chains to be added to batches ([#34612](https://github.com/laravel/framework/pull/34612), [7b4a9ec](https://github.com/laravel/framework/commit/7b4a9ec6c58906eb73957015e4c78f73e780e944)) +- Added `is()` method to 1-1 relations for model comparison ([#34693](https://github.com/laravel/framework/pull/34693), [7ba2577](https://github.com/laravel/framework/commit/7ba257732d2342175a6ffe7db7a4ca847ca1d353)) +- Added `upsert()` to Eloquent and Base Query Builders ([#34698](https://github.com/laravel/framework/pull/34698), [#34712](https://github.com/laravel/framework/pull/34712), [58a0e1b](https://github.com/laravel/framework/commit/58a0e1b7e2bb6df3923883c4fc8cf13b1bce7322)) +- Support psql and pg_restore commands in schema load ([#34711](https://github.com/laravel/framework/pull/34711)) +- Added `Illuminate\Database\Schema\Builder::dropColumns()` method on the schema class ([#34720](https://github.com/laravel/framework/pull/34720)) +- Added `yearlyOn()` method to scheduler ([#34728](https://github.com/laravel/framework/pull/34728)) +- Added `restrictOnDelete()` method to ForeignKeyDefinition class ([#34752](https://github.com/laravel/framework/pull/34752)) +- Added `newLine()` method to `InteractsWithIO` trait ([#34754](https://github.com/laravel/framework/pull/34754)) +- Added `isNotEmpty()` method to HtmlString ([#34774](https://github.com/laravel/framework/pull/34774)) +- Added `delay()` to PendingChain ([#34789](https://github.com/laravel/framework/pull/34789)) +- Added "multiple_of" validation rule ([#34788](https://github.com/laravel/framework/pull/34788)) +- Added custom methods proxy support for jobs `dispatch()` ([#34781](https://github.com/laravel/framework/pull/34781)) +- Added `QueryBuilder::clone()` ([#34780](https://github.com/laravel/framework/pull/34780)) +- Support bus chain on fake ([a952ac24](https://github.com/laravel/framework/commit/a952ac24f34b832270a2f80cd425c2afe4c61fc1)) +- Added missing force flag to `queue:clear` command ([#34809](https://github.com/laravel/framework/pull/34809)) +- Added `dropConstrainedForeignId()` to `Blueprint ([#34806](https://github.com/laravel/framework/pull/34806)) +- Implement `supportsTags()` on the Cache Repository ([#34820](https://github.com/laravel/framework/pull/34820)) +- Added `canAny` to user model ([#34815](https://github.com/laravel/framework/pull/34815)) +- Added `when()` and `unless()` methods to MailMessage ([#34814](https://github.com/laravel/framework/pull/34814)) + +### Fixed +- Fixed collection wrapping in `BelongsToManyRelationship` ([9245807](https://github.com/laravel/framework/commit/9245807f8a1132a30ce669513cf0e99e9e078267)) +- Fixed `LengthAwarePaginator` translations issue ([#34714](https://github.com/laravel/framework/pull/34714)) + +### Changed +- Improve `schedule:work` command ([#34736](https://github.com/laravel/framework/pull/34736), [bbddba2](https://github.com/laravel/framework/commit/bbddba279bc781fc2868a6967430943de636614f)) +- Guard against invalid guard in `make:policy` ([#34792](https://github.com/laravel/framework/pull/34792)) +- Fixed router inconsistency for namespaced route groups ([#34793](https://github.com/laravel/framework/pull/34793)) + + +## [v8.9.0 (2020-10-06)](https://github.com/laravel/framework/compare/v8.8.0...v8.9.0) + +### Added +- Added support `times()` with `raw()` from `Illuminate\Database\Eloquent\Factories\Factory` ([#34667](https://github.com/laravel/framework/pull/34667)) +- Added `Illuminate\Pagination\AbstractPaginator::through()` ([#34657](https://github.com/laravel/framework/pull/34657)) +- Added `extendsFirst()` method similar to `includesFirst()` to view ([#34648](https://github.com/laravel/framework/pull/34648)) +- Allowed `Illuminate\Http\Client\PendingRequest::attach()` method to accept many files ([#34697](https://github.com/laravel/framework/pull/34697), [1bb7ad6](https://github.com/laravel/framework/commit/1bb7ad664a3607f719af2d91c3f95cf71662dcd2)) +- Allowed serializing custom casts when converting a model to an array ([#34702](https://github.com/laravel/framework/pull/34702)) + +### Fixed +- Added missed RESET_THROTTLED constant to Password Facade ([#34641](https://github.com/laravel/framework/pull/34641)) +- Fixed queue clearing when blocking ([#34659](https://github.com/laravel/framework/pull/34659)) +- Fixed missing import in TestView.php ([#34677](https://github.com/laravel/framework/pull/34677)) +- Use `getRealPath` to ensure console command class names are generated correctly in `Illuminate\Foundation\Console\Kernel` ([#34653](https://github.com/laravel/framework/pull/34653)) +- Added `pg_dump --no-owner` and `--no-acl` to avoid owner/permission issues in `Illuminate\Database\Schema\PostgresSchemaState::baseDumpCommand()` ([#34689](https://github.com/laravel/framework/pull/34689)) +- Fixed `queue:failed` command when Class not exists ([#34696](https://github.com/laravel/framework/pull/34696)) + +### Performance +- Increase performance of `Str::before()` by over 60% ([#34642](https://github.com/laravel/framework/pull/34642)) + + +## [v8.8.0 (2020-10-02)](https://github.com/laravel/framework/compare/v8.7.1...v8.8.0) + +### Added +- Proxy URL Generation in `VerifyEmail` ([#34572](https://github.com/laravel/framework/pull/34572)) +- Added `Illuminate\Collections\Traits\EnumeratesValues::pipeInto()` ([#34600](https://github.com/laravel/framework/pull/34600)) +- Added `Illuminate\Http\Client\PendingRequest::withUserAgent()` ([#34611](https://github.com/laravel/framework/pull/34611)) +- Added `schedule:work` command ([#34618](https://github.com/laravel/framework/pull/34618)) +- Added support for appendable (prepends) component attributes ([09b887b](https://github.com/laravel/framework/commit/09b887b85614d3e2539e74f40d7aa9c1c9f903d3), [53fbc9f](https://github.com/laravel/framework/commit/53fbc9f3768f611c960a5d891a1abb259163978a)) + +### Fixed +- Fixed `Illuminate\Http\Client\Response::throw()` ([#34597](https://github.com/laravel/framework/pull/34597)) +- Fixed breaking change in migrate command ([b2a3641](https://github.com/laravel/framework/commit/b2a36411a774dba218fa312b8fd3bcf4be44a4e5)) + +### Changed +- Changing the dump and restore method for a PostgreSQL database ([#34293](https://github.com/laravel/framework/pull/34293)) + + +## [v8.7.1 (2020-09-29)](https://github.com/laravel/framework/compare/v8.7.0...v8.7.1) + +### Fixed +- Remove type hints ([1b3f62a](https://github.com/laravel/framework/commit/1b3f62aaeced2c9761a6052a7f0d3c1a046851c9)) + + +## [v8.7.0 (2020-09-29)](https://github.com/laravel/framework/compare/v8.6.0...v8.7.0) + +### Added +- Added `tg://` protocol in "url" validation rule ([#34464](https://github.com/laravel/framework/pull/34464)) +- Allow dynamic factory methods to obey newFactory method on model ([#34492](https://github.com/laravel/framework/pull/34492), [4708e9e](https://github.com/laravel/framework/commit/4708e9ef8f7cde617a5820f07cfd350daaba0e0f)) +- Added `no-reload` option to `serve` command ([9cc2622](https://github.com/laravel/framework/commit/9cc2622a9122f5108a694856055c13db8a5f80dc)) +- Added `perHour()` and `perDay()` methods to `Illuminate\Cache\RateLimiting\Limit` ([#34530](https://github.com/laravel/framework/pull/34530)) +- Added `Illuminate\Http\Client\Response::onError()` ([#34558](https://github.com/laravel/framework/pull/34558), [d034e2c](https://github.com/laravel/framework/commit/d034e2c55c6502fa0c2bebb6cbf99c5e685beaa5)) +- Added `X-Message-ID` to `Mailgun` and `Ses Transport` ([#34567](https://github.com/laravel/framework/pull/34567)) + +### Fixed +- Fixed incompatibility with Lumen route function in `Illuminate\Session\Middleware\StartSession` ([#34491](https://github.com/laravel/framework/pull/34491)) +- Fixed: Eager loading MorphTo relationship does not honor each models `$keyType` ([#34531](https://github.com/laravel/framework/pull/34531), [c3f44c7](https://github.com/laravel/framework/commit/c3f44c712833d83061452e9a362a5e10fa424863)) +- Fixed translation label ("Pagination Navigation") for the Tailwind blade ([#34568](https://github.com/laravel/framework/pull/34568)) +- Fixed save keys on increment / decrement in Model ([77db028](https://github.com/laravel/framework/commit/77db028225ccd6ec6bc3359f69482f2e4cc95faf)) + +### Changed +- Allow modifiers in date format in Model ([#34507](https://github.com/laravel/framework/pull/34507)) +- Allow for dynamic calls of anonymous component with varied attributes ([#34498](https://github.com/laravel/framework/pull/34498)) +- Cast `Expression` as string so it can be encoded ([#34569](https://github.com/laravel/framework/pull/34569)) + + +## [v8.6.0 (2020-09-22)](https://github.com/laravel/framework/compare/v8.5.0...v8.6.0) + +### Added +- Added `Illuminate\Collections\LazyCollection::takeUntilTimeout()` ([0aabf24](https://github.com/laravel/framework/commit/0aabf2472850a9d573907ca092bf5e3cfe26fab3)) +- Added `--schema-path` option to `migrate:fresh` command ([#34419](https://github.com/laravel/framework/pull/34419)) + +### Fixed +- Fixed problems with dots in validator ([#34355](https://github.com/laravel/framework/pull/34355)) +- Maintenance mode: Fix empty Retry-After header ([#34412](https://github.com/laravel/framework/pull/34412)) +- Fixed bug with error handling in closure scheduled tasks ([#34420](https://github.com/laravel/framework/pull/34420)) +- Don't double escape on `ComponentTagCompiler.php` ([12ba0d9](https://github.com/laravel/framework/commit/12ba0d937d54e81eccf8f0a80150f0d70604e1c2)) +- Fixed `mysqldump: unknown variable 'column-statistics=0` for MariaDB schema dump ([#34442](https://github.com/laravel/framework/pull/34442)) + + +## [v8.5.0 (2020-09-19)](https://github.com/laravel/framework/compare/v8.4.0...v8.5.0) + +### Added +- Allow clearing an SQS queue by `queue:clear` command ([#34383](https://github.com/laravel/framework/pull/34383), [de811ea](https://github.com/laravel/framework/commit/de811ea7f7dc7ecfc686b25fba48e4b0dac473e6)) +- Added `Illuminate\Foundation\Auth\EmailVerificationRequest` ([4bde31b](https://github.com/laravel/framework/commit/4bde31b24bf01b4d4a35ad31fafd8e4ca203b0f2)) +- Auto handle `Jsonable` values passed to `castAsJson()` ([#34392](https://github.com/laravel/framework/pull/34392)) +- Added `crossJoinSub()` method to the query builder ([#34400](https://github.com/laravel/framework/pull/34400)) +- Added `Illuminate\Session\Store::passwordConfirmed()` ([fb3f45a](https://github.com/laravel/framework/commit/fb3f45aa0142764c5c29b97e8bcf8328091986e9)) + +### Changed +- Check for view existence first in `Illuminate\Mail\Markdown::render()` ([5f78c90](https://github.com/laravel/framework/commit/5f78c90a7af118dd07703a78da06586016973a66)) +- Guess the model name when using the `make:factory` command ([#34373](https://github.com/laravel/framework/pull/34373)) + + +## [v8.4.0 (2020-09-16)](https://github.com/laravel/framework/compare/v8.3.0...v8.4.0) + +### Added +- Added SQLite schema dump support ([#34323](https://github.com/laravel/framework/pull/34323)) +- Added `queue:clear` command ([#34330](https://github.com/laravel/framework/pull/34330), [06b378c](https://github.com/laravel/framework/commit/06b378c07b2ea989aa3e947ca003e96ea277153c)) + +### Fixed +- Fixed `minimal.blade.php` ([#34379](https://github.com/laravel/framework/pull/34379)) +- Don't double escape on ComponentTagCompiler.php ([ec75487](https://github.com/laravel/framework/commit/ec75487062506963dd27a4302fe3680c0e3681a3)) +- Fixed dots in attribute names in `DynamicComponent` ([2d1d962](https://github.com/laravel/framework/commit/2d1d96272a94bce123676ed742af2d80ba628ba4)) + +### Changed +- Show warning when view exists when using artisan `make:component` ([#34376](https://github.com/laravel/framework/pull/34376), [0ce75e0](https://github.com/laravel/framework/commit/0ce75e01a66ba4b13bbe4cbed85564f1dc76bb05)) +- Call the booting/booted callbacks from the container ([#34370](https://github.com/laravel/framework/pull/34370)) + + +## [v8.3.0 (2020-09-15)](https://github.com/laravel/framework/compare/v8.2.0...v8.3.0) + +### Added +- Added `Illuminate\Foundation\Testing\Concerns\InteractsWithDatabase::castAsJson()` ([#34302](https://github.com/laravel/framework/pull/34302)) +- Handle array hosts in `Illuminate\Database\Schema\MySqlSchemaState` ([0920c23](https://github.com/laravel/framework/commit/0920c23efb9d7042d074729f2f70acbfec629c14)) +- Added `Illuminate\Pipeline\Pipeline::setContainer()` ([#34343](https://github.com/laravel/framework/pull/34343)) +- Allow including a closure in a queued batch ([#34333](https://github.com/laravel/framework/pull/34333)) + +### Fixed +- Fixed broken Seeder ([9e4a866](https://github.com/laravel/framework/commit/9e4a866cfb0420f4ea6cb4e86b1fbd97a4b8c264)) + +### Changed +- Bumped minimum vlucas/phpdotenv version ([#34336](https://github.com/laravel/framework/pull/34336)) +- Pass an instance of the job to queued closures ([#34350](https://github.com/laravel/framework/pull/34350)) + + +## [v8.2.0 (2020-09-14)](https://github.com/laravel/framework/compare/v8.1.0...v8.2.0) + +### Added +- Added `Illuminate\Database\Eloquent\Factories\HasFactory::newFactory()` ([4a95372](https://github.com/laravel/framework/commit/4a953728f5e085342d793372329ae534e5885724), [a2cea84](https://github.com/laravel/framework/commit/a2cea84805f311be612fc36c403fcc6f90181ff4)) + +### Fixed +- Do not used `now` helper in `Illuminate/Cache/DatabaseLock::expiresAt()` ([#34262](https://github.com/laravel/framework/pull/34262)) +- Change placeholder in `Illuminate\Database\Schema\MySqlSchemaState::load()` ([#34303](https://github.com/laravel/framework/pull/34303)) +- Fixed bug in dynamic attributes `Illuminate\View\ComponentAttributeBag::setAttributes()` ([93f4613](https://github.com/laravel/framework/commit/93f461344051e8d44c4a50748b7bdc0eae18bcac)) +- Fixed `Illuminate\View\ComponentAttributeBag::whereDoesntStartWith()` ([#34329](https://github.com/laravel/framework/pull/34329)) +- Fixed `Illuminate\Routing\Middleware\ThrottleRequests::handleRequestUsingNamedLimiter()` ([#34325](https://github.com/laravel/framework/pull/34325)) + +### Changed +- Create Faker when a Factory is created ([#34298](https://github.com/laravel/framework/pull/34298)) + + +## [v8.1.0 (2020-09-11)](https://github.com/laravel/framework/compare/v8.0.4...v8.1.0) + +### Added +- Added `Illuminate\Database\Eloquent\Factories\Factory::raw()` ([#34278](https://github.com/laravel/framework/pull/34278)) +- Added `Illuminate\Database\Eloquent\Factories\Factory::createMany()` ([#34285](https://github.com/laravel/framework/pull/34285), [69072c7](https://github.com/laravel/framework/commit/69072c7d3efd2784d195cb95e45e4dcb8ef5907f)) +- Added the `Countable` interface to `AssertableJsonString` ([#34284](https://github.com/laravel/framework/pull/34284)) + +### Fixed +- Fixed the new maintenance mode ([#34264](https://github.com/laravel/framework/pull/34264)) + +### Changed +- Optimize command can also cache view ([#34287](https://github.com/laravel/framework/pull/34287)) + + +## [v8.0.4 (2020-09-11)](https://github.com/laravel/framework/compare/v8.0.3...v8.0.4) + +### Changed +- Allow `Illuminate\Collections\Collection::implode()` when instance of `Stringable` ([#34271](https://github.com/laravel/framework/pull/34271)) + +### Fixed +- Fixed `DatabaseUuidFailedJobProvider::find()` job record structure ([#34251](https://github.com/laravel/framework/pull/34251)) +- Cast linkCollection to array in JSON pagination responses ([#34245](https://github.com/laravel/framework/pull/34245)) +- Change the placeholder of schema dump according to symfony placeholder in `MySqlSchemaState::dump()` ([#34261](https://github.com/laravel/framework/pull/34261)) +- Fixed problems with dots in validator ([8723739](https://github.com/laravel/framework/commit/8723739746a53442a5ec5bdebe649f8a4d9dd3c2)) + + +## [v8.0.3 (2020-09-10)](https://github.com/laravel/framework/compare/v8.0.2...v8.0.3) + +### Added +- Added links property to JSON pagination responses ([13751a1](https://github.com/laravel/framework/commit/13751a187834fabe515c14fb3ac1dc008fd23f37)) + +### Fixed +- Fixed bugs with factory creation in `FactoryMakeCommand` ([c7186e0](https://github.com/laravel/framework/commit/c7186e09204cb3ed72ab24fe9f25a6450c2512bb)) + + +## [v8.0.2 (2020-09-09)](https://github.com/laravel/framework/compare/v8.0.1...v8.0.2) + +### Revert +- Revert of ["Fixed for empty fallback_locale in `Illuminate\Translation\Translator`"](https://github.com/laravel/framework/pull/34136) ([7c54eb6](https://github.com/laravel/framework/commit/7c54eb678d58fb9ee7f532a5a5842e6f0e1fe4c9)) + +### Changed +- Update `Illuminate\Database\Schema\MySqlSchemaState::executeDumpProcess()` ([#34233](https://github.com/laravel/framework/pull/34233)) + + +## [v8.0.1 (2020-09-09)](https://github.com/laravel/framework/compare/v8.0.0...v8.0.1) + +### Added +- Support array syntax in `Illuminate\Routing\Route::uses()` ([f80ba11](https://github.com/laravel/framework/commit/f80ba11b698b6130bdbc7ffdcb947519deabbdba)) + +### Fixed +- Fixed `BatchRepositoryFake` TypeError ([#34225](https://github.com/laravel/framework/pull/34225)) +- Fixed dynamic component bug ([4b1e317](https://github.com/laravel/framework/commit/4b1e317c7aec22c2767766bb8b84e059fe4e0802)) + +### Changed +- Give shadow a rounded edge to match content in `tailwind.blade.php` ([#34198](https://github.com/laravel/framework/pull/34198)) +- Pass the request to the renderable callback in `Illuminate\Foundation\Exceptions\Handler::render()` ([#34200](https://github.com/laravel/framework/pull/34200)) +- Update `Illuminate\Database\Schema\MySqlSchemaState` ([d67be130](https://github.com/laravel/framework/commit/d67be1305bef418d9bdeb8192177202f9d705699), [c87794f](https://github.com/laravel/framework/commit/c87794fc354941729d1f0c4607693c0b8d2cfda2)) +- Respect local env in `Illuminate\Foundation\Console\ServeCommand::startProcess()` ([75e792d](https://github.com/laravel/framework/commit/75e792d61871780f75ecb4eb170826b0ba2f305e)) + + +## [v8.0.0 (2020-09-08)](https://github.com/laravel/framework/compare/v7.27.0...v8.0.0) + +Check the upgrade guide in the [Official Laravel Upgrade Documentation](https://laravel.com/docs/8.x/upgrade). Also you can see some release notes in the [Official Laravel Release Documentation](https://laravel.com/docs/8.x/releases). diff --git a/README.md b/README.md index ef4bc184428e03ed2d921a917397d5429311ed87..6e9702b3a98c873745b0dad0c063e4abfbcf521b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ <p align="center"> <a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a> -<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/d/total.svg" alt="Total Downloads"></a> -<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/v/stable.svg" alt="Latest Stable Version"></a> -<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/license.svg" alt="License"></a> +<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a> +<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a> +<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a> </p> ## About Laravel diff --git a/bin/release.sh b/bin/release.sh index 7078367e74aea3f494a972e23327194053b1eebc..6ff44b88663f47f66f27b326b96203025a25e31c 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -10,7 +10,7 @@ then exit 1 fi -RELEASE_BRANCH="6.x" +RELEASE_BRANCH="8.x" CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) VERSION=$1 @@ -52,7 +52,7 @@ git tag $VERSION git push origin --tags # Tag Components -for REMOTE in auth broadcasting bus cache config console container contracts cookie database encryption events filesystem hashing http log mail notifications pagination pipeline queue redis routing session support translation validation view +for REMOTE in auth broadcasting bus cache collections config console container contracts cookie database encryption events filesystem hashing http log macroable mail notifications pagination pipeline queue redis routing session support testing translation validation view do echo "" echo "" diff --git a/bin/split.sh b/bin/split.sh index c0d2aec8fa2819cfe989f40222eafcb9806b1aa3..f27bdf8d1a3b166d2ccf788b0fb4cd421dfb48f0 100755 --- a/bin/split.sh +++ b/bin/split.sh @@ -3,7 +3,7 @@ set -e set -x -CURRENT_BRANCH="6.x" +CURRENT_BRANCH="8.x" function split() { @@ -22,6 +22,7 @@ remote auth git@github.com:illuminate/auth.git remote broadcasting git@github.com:illuminate/broadcasting.git remote bus git@github.com:illuminate/bus.git remote cache git@github.com:illuminate/cache.git +remote collections git@github.com:illuminate/collections.git remote config git@github.com:illuminate/config.git remote console git@github.com:illuminate/console.git remote container git@github.com:illuminate/container.git @@ -34,6 +35,7 @@ remote filesystem git@github.com:illuminate/filesystem.git remote hashing git@github.com:illuminate/hashing.git remote http git@github.com:illuminate/http.git remote log git@github.com:illuminate/log.git +remote macroable git@github.com:illuminate/macroable.git remote mail git@github.com:illuminate/mail.git remote notifications git@github.com:illuminate/notifications.git remote pagination git@github.com:illuminate/pagination.git @@ -43,6 +45,7 @@ remote redis git@github.com:illuminate/redis.git remote routing git@github.com:illuminate/routing.git remote session git@github.com:illuminate/session.git remote support git@github.com:illuminate/support.git +remote testing git@github.com:illuminate/testing.git remote translation git@github.com:illuminate/translation.git remote validation git@github.com:illuminate/validation.git remote view git@github.com:illuminate/view.git @@ -51,6 +54,7 @@ split 'src/Illuminate/Auth' auth split 'src/Illuminate/Broadcasting' broadcasting split 'src/Illuminate/Bus' bus split 'src/Illuminate/Cache' cache +split 'src/Illuminate/Collections' collections split 'src/Illuminate/Config' config split 'src/Illuminate/Console' console split 'src/Illuminate/Container' container @@ -63,6 +67,7 @@ split 'src/Illuminate/Filesystem' filesystem split 'src/Illuminate/Hashing' hashing split 'src/Illuminate/Http' http split 'src/Illuminate/Log' log +split 'src/Illuminate/Macroable' macroable split 'src/Illuminate/Mail' mail split 'src/Illuminate/Notifications' notifications split 'src/Illuminate/Pagination' pagination @@ -72,6 +77,7 @@ split 'src/Illuminate/Redis' redis split 'src/Illuminate/Routing' routing split 'src/Illuminate/Session' session split 'src/Illuminate/Support' support +split 'src/Illuminate/Testing' testing split 'src/Illuminate/Translation' translation split 'src/Illuminate/Validation' validation split 'src/Illuminate/View' view diff --git a/bin/test.sh b/bin/test.sh index 43a41314d7e865b8f016669343e9f5660d59e2ba..a03fd3dd6530081d9c3dcc27eb625700e21c488c 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -1,23 +1,50 @@ #!/usr/bin/env bash -docker-compose down -t 0 &> /dev/null +down=false +php="8.0" + +while true; do + case "$1" in + --down ) down=true; shift ;; + --php ) php=$2; shift 2;; + -- ) shift; break ;; + * ) break ;; + esac +done + +if $down; then + docker-compose down -t 0 + + exit 0 +fi + +echo "Ensuring docker is running" + +if ! docker info > /dev/null 2>&1; then + echo "Please start docker first." + exit 1 +fi + +echo "Ensuring services are running" + docker-compose up -d -echo "Waiting for services to boot ..." +if docker run -it --rm "registry.gitlab.com/grahamcampbell/php:$php-base" -r "\$tries = 0; while (true) { try { \$tries++; if (\$tries > 30) { throw new RuntimeException('MySQL never became available'); } sleep(1); new PDO('mysql:host=docker.for.mac.localhost;dbname=forge', 'root', '', [PDO::ATTR_TIMEOUT => 3]); break; } catch (PDOException \$e) {} }"; then + echo "Running tests" -if docker run -it --rm registry.gitlab.com/grahamcampbell/php:7.4-base -r "\$tries = 0; while (true) { try { \$tries++; if (\$tries > 30) { throw new RuntimeException('MySQL never became available'); } sleep(1); new PDO('mysql:host=docker.for.mac.localhost;dbname=forge', 'root', '', [PDO::ATTR_TIMEOUT => 3]); break; } catch (PDOException \$e) {} }"; then - if docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/phpunit \ + if docker run -it -w /data -v ${PWD}:/data:delegated \ + --user "www-data" --entrypoint vendor/bin/phpunit \ --env CI=1 --env DB_HOST=docker.for.mac.localhost --env DB_USERNAME=root \ + --env DB_HOST=docker.for.mac.localhost --env DB_PORT=3306 \ + --env DYNAMODB_ENDPOINT=docker.for.mac.localhost:8000 --env DYNAMODB_CACHE_TABLE=cache --env AWS_ACCESS_KEY_ID=dummy --env AWS_SECRET_ACCESS_KEY=dummy \ --env REDIS_HOST=docker.for.mac.localhost --env REDIS_PORT=6379 \ --env MEMCACHED_HOST=docker.for.mac.localhost --env MEMCACHED_PORT=11211 \ - --rm registry.gitlab.com/grahamcampbell/php:7.4-base "$@"; then - docker-compose down -t 0 + --rm "registry.gitlab.com/grahamcampbell/php:$php-base" "$@"; then + exit 0 else - docker-compose down -t 0 exit 1 fi else docker-compose logs - docker-compose down -t 0 &> /dev/null exit 1 fi diff --git a/composer.json b/composer.json index e32e059170fed4d69d1a3db0e64f78ee09a55879..ea014010f5fde3ecfe1278a9d79d966971f0bbb3 100644 --- a/composer.json +++ b/composer.json @@ -15,39 +15,43 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", "doctrine/inflector": "^1.4|^2.0", - "dragonmantank/cron-expression": "^2.3.1", + "dragonmantank/cron-expression": "^3.0.2", "egulias/email-validator": "^2.1.10", - "league/commonmark": "^1.3", + "laravel/serializable-closure": "^1.0", + "league/commonmark": "^1.3|^2.0.2", "league/flysystem": "^1.1", - "monolog/monolog": "^1.12|^2.0", - "nesbot/carbon": "^2.31", + "monolog/monolog": "^2.0", + "nesbot/carbon": "^2.53.1", "opis/closure": "^3.6", "psr/container": "^1.0", + "psr/log": "^1.0|^2.0", "psr/simple-cache": "^1.0", - "ramsey/uuid": "^3.7", - "swiftmailer/swiftmailer": "^6.0", - "symfony/console": "^4.3.4", - "symfony/debug": "^4.3.4", - "symfony/finder": "^4.3.4", - "symfony/http-foundation": "^4.3.4", - "symfony/http-kernel": "^4.3.4", - "symfony/polyfill-php73": "^1.17", - "symfony/process": "^4.3.4", - "symfony/routing": "^4.3.4", - "symfony/var-dumper": "^4.3.4", - "tijsverkoyen/css-to-inline-styles": "^2.2.1", - "vlucas/phpdotenv": "^3.3" + "ramsey/uuid": "^4.2.2", + "swiftmailer/swiftmailer": "^6.3", + "symfony/console": "^5.4", + "symfony/error-handler": "^5.4", + "symfony/finder": "^5.4", + "symfony/http-foundation": "^5.4", + "symfony/http-kernel": "^5.4", + "symfony/mime": "^5.4", + "symfony/process": "^5.4", + "symfony/routing": "^5.4", + "symfony/var-dumper": "^5.4", + "tijsverkoyen/css-to-inline-styles": "^2.2.2", + "vlucas/phpdotenv": "^5.4.1", + "voku/portable-ascii": "^1.6.1" }, "replace": { "illuminate/auth": "self.version", "illuminate/broadcasting": "self.version", "illuminate/bus": "self.version", "illuminate/cache": "self.version", + "illuminate/collections": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", "illuminate/container": "self.version", @@ -60,6 +64,7 @@ "illuminate/hashing": "self.version", "illuminate/http": "self.version", "illuminate/log": "self.version", + "illuminate/macroable": "self.version", "illuminate/mail": "self.version", "illuminate/notifications": "self.version", "illuminate/pagination": "self.version", @@ -69,34 +74,41 @@ "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", + "illuminate/testing": "self.version", "illuminate/translation": "self.version", "illuminate/validation": "self.version", "illuminate/view": "self.version" }, - "conflict": { - "tightenco/collect": "<5.5.33" - }, "require-dev": { - "aws/aws-sdk-php": "^3.155", - "doctrine/dbal": "^2.6", - "filp/whoops": "^2.8", - "guzzlehttp/guzzle": "^6.3.1|^7.0.1", + "aws/aws-sdk-php": "^3.198.1", + "doctrine/dbal": "^2.13.3|^3.1.4", + "filp/whoops": "^2.14.3", + "guzzlehttp/guzzle": "^6.5.5|^7.0.1", "league/flysystem-cached-adapter": "^1.0", - "mockery/mockery": "~1.3.3|^1.4.2", - "moontoast/math": "^1.1", - "orchestra/testbench-core": "^4.8", + "mockery/mockery": "^1.4.4", + "orchestra/testbench-core": "^6.27", "pda/pheanstalk": "^4.0", - "phpunit/phpunit": "^7.5.15|^8.4|^9.3.3", - "predis/predis": "^1.1.1", - "symfony/cache": "^4.3.4" + "phpunit/phpunit": "^8.5.19|^9.5.8", + "predis/predis": "^1.1.9", + "symfony/cache": "^5.4" + }, + "provide": { + "psr/container-implementation": "1.0", + "psr/simple-cache-implementation": "1.0" + }, + "conflict": { + "tightenco/collect": "<5.5.33" }, "autoload": { "files": [ + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { - "Illuminate\\": "src/Illuminate/" + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": ["src/Illuminate/Macroable/", "src/Illuminate/Collections/"] } }, "autoload-dev": { @@ -109,37 +121,45 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { + "ext-bcmath": "Required to use the multiple_of validation rule.", "ext-ftp": "Required to use the Flysystem FTP driver.", "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", "ext-memcached": "Required to use the memcache cache driver.", "ext-pcntl": "Required to use all features of the queue worker.", "ext-posix": "Required to use all features of the queue worker.", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.155).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.6).", - "filp/whoops": "Required for friendly error pages in development (^2.8).", + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.198.1).", + "brianium/paratest": "Required to run tests in parallel (^6.0).", + "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", - "guzzlehttp/guzzle": "Required to use the Mailgun mail driver and the ping methods on schedules (^6.3.1|^7.0.1).", + "guzzlehttp/guzzle": "Required to use the HTTP Client, Mailgun mail driver and the ping methods on schedules (^6.5.5|^7.0.1).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).", "league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).", "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).", - "moontoast/math": "Required to use ordered UUIDs (^1.1).", + "mockery/mockery": "Required to use mocking (^1.4.4).", "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "predis/predis": "Required to use the predis connector (^1.1.2).", + "phpunit/phpunit": "Required to use assertions and run tests (^8.5.19|^9.5.8).", + "predis/predis": "Required to use the predis connector (^1.1.9).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^4.3.4).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^1.2).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^5.4).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^5.4).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0).", "wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)." }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true + } }, "minimum-stability": "dev", "prefer-stable": true diff --git a/docker-compose.yml b/docker-compose.yml index 4b129f911cfc67329147a2d032ad42ca266e5f86..d29e01cc635af3fade86558bbb9dc4c05f322497 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,22 @@ version: '3' services: + dynamodb: + image: amazon/dynamodb-local + ports: + - "8000:8000" + command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"] memcached: image: memcached:1.6-alpine ports: - "11211:11211" restart: always mysql: - image: mysql:5.7 + image: mysql/mysql-server:5.7 environment: MYSQL_ALLOW_EMPTY_PASSWORD: 1 MYSQL_ROOT_PASSWORD: "" MYSQL_DATABASE: "forge" + MYSQL_ROOT_HOST: "%" ports: - "3306:3306" restart: always diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bb20f5f6ded1d67c6fbdc5f2269f631d56d640c6..cc45c172e33cb517c107882c545519066ed5dd78 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,11 +2,12 @@ <phpunit backupGlobals="false" backupStaticAttributes="false" beStrictAboutTestsThatDoNotTestAnything="false" - bootstrap="tests/bootstrap.php" colors="true" + convertDeprecationsToExceptions="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" + printerClass="Illuminate\Tests\IgnoreSkippedPrinter" processIsolation="false" stopOnError="false" stopOnFailure="false" @@ -18,7 +19,10 @@ </testsuite> </testsuites> <php> + <ini name="date.timezone" value="UTC" /> + <ini name="intl.default_locale" value="C.UTF-8" /> <ini name="memory_limit" value="2048M" /> + <env name="DB_CONNECTION" value="testing" /> <!-- <env name="REDIS_HOST" value="127.0.0.1" /> <env name="REDIS_PORT" value="6379" /> diff --git a/src/Illuminate/Auth/Access/AuthorizationException.php b/src/Illuminate/Auth/Access/AuthorizationException.php index 126da3069dced8ca610094559e5727593ba61c46..7fe6ceba9581748ecdd660d2df01ad29dd3098eb 100644 --- a/src/Illuminate/Auth/Access/AuthorizationException.php +++ b/src/Illuminate/Auth/Access/AuthorizationException.php @@ -3,6 +3,7 @@ namespace Illuminate\Auth\Access; use Exception; +use Throwable; class AuthorizationException extends Exception { @@ -18,10 +19,10 @@ class AuthorizationException extends Exception * * @param string|null $message * @param mixed $code - * @param \Exception|null $previous + * @param \Throwable|null $previous * @return void */ - public function __construct($message = null, $code = null, Exception $previous = null) + public function __construct($message = null, $code = null, Throwable $previous = null) { parent::__construct($message ?? 'This action is unauthorized.', 0, $previous); diff --git a/src/Illuminate/Auth/Access/Events/GateEvaluated.php b/src/Illuminate/Auth/Access/Events/GateEvaluated.php new file mode 100644 index 0000000000000000000000000000000000000000..f77a9c84c51bdfdc3a375851bda96b4c38c6af7e --- /dev/null +++ b/src/Illuminate/Auth/Access/Events/GateEvaluated.php @@ -0,0 +1,51 @@ +<?php + +namespace Illuminate\Auth\Access\Events; + +class GateEvaluated +{ + /** + * The authenticatable model. + * + * @var \Illuminate\Contracts\Auth\Authenticatable|null + */ + public $user; + + /** + * The ability being evaluated. + * + * @var string + */ + public $ability; + + /** + * The result of the evaluation. + * + * @var bool|null + */ + public $result; + + /** + * The arguments given during evaluation. + * + * @var array + */ + public $arguments; + + /** + * Create a new event instance. + * + * @param \Illuminate\Contracts\Auth\Authenticatable|null $user + * @param string $ability + * @param bool|null $result + * @param array $arguments + * @return void + */ + public function __construct($user, $ability, $result, $arguments) + { + $this->user = $user; + $this->ability = $ability; + $this->result = $result; + $this->arguments = $arguments; + } +} diff --git a/src/Illuminate/Auth/Access/Gate.php b/src/Illuminate/Auth/Access/Gate.php index 9cc701561ea450b0c40932bb2641805f09aecd24..fe8d93fcb4ec6beb8d9d924fea3ce12631124eef 100644 --- a/src/Illuminate/Auth/Access/Gate.php +++ b/src/Illuminate/Auth/Access/Gate.php @@ -2,10 +2,13 @@ namespace Illuminate\Auth\Access; +use Closure; use Exception; use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use InvalidArgumentException; use ReflectionClass; @@ -115,6 +118,64 @@ class Gate implements GateContract return true; } + /** + * Perform an on-demand authorization check. Throw an authorization exception if the condition or callback is false. + * + * @param \Illuminate\Auth\Access\Response|\Closure|bool $condition + * @param string|null $message + * @param string|null $code + * @return \Illuminate\Auth\Access\Response + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function allowIf($condition, $message = null, $code = null) + { + return $this->authorizeOnDemand($condition, $message, $code, true); + } + + /** + * Perform an on-demand authorization check. Throw an authorization exception if the condition or callback is true. + * + * @param \Illuminate\Auth\Access\Response|\Closure|bool $condition + * @param string|null $message + * @param string|null $code + * @return \Illuminate\Auth\Access\Response + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function denyIf($condition, $message = null, $code = null) + { + return $this->authorizeOnDemand($condition, $message, $code, false); + } + + /** + * Authorize a given condition or callback. + * + * @param \Illuminate\Auth\Access\Response|\Closure|bool $condition + * @param string|null $message + * @param string|null $code + * @param bool $allowWhenResponseIs + * @return \Illuminate\Auth\Access\Response + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + protected function authorizeOnDemand($condition, $message, $code, $allowWhenResponseIs) + { + $user = $this->resolveUser(); + + if ($condition instanceof Closure) { + $response = $this->canBeCalledWithUser($user, $condition) + ? $condition($user) + : new Response(false, $message, $code); + } else { + $response = $condition; + } + + return with($response instanceof Response ? $response : new Response( + (bool) $response === $allowWhenResponseIs, $message, $code + ))->authorize(); + } + /** * Define a new ability. * @@ -126,6 +187,10 @@ class Gate implements GateContract */ public function define($ability, $callback) { + if (is_array($callback) && isset($callback[0]) && is_string($callback[0])) { + $callback = $callback[0].'@'.$callback[1]; + } + if (is_callable($callback)) { $this->abilities[$ability] = $callback; } elseif (is_string($callback)) { @@ -151,10 +216,10 @@ class Gate implements GateContract { $abilities = $abilities ?: [ 'viewAny' => 'viewAny', - 'view' => 'view', - 'create' => 'create', - 'update' => 'update', - 'delete' => 'delete', + 'view' => 'view', + 'create' => 'create', + 'update' => 'update', + 'delete' => 'delete', ]; foreach ($abilities as $ability => $method) { @@ -369,9 +434,11 @@ class Gate implements GateContract // After calling the authorization callback, we will call the "after" callbacks // that are registered with the Gate, which allows a developer to do logging // if that is required for this application. Then we'll return the result. - return $this->callAfterCallbacks( + return tap($this->callAfterCallbacks( $user, $ability, $arguments, $result - ); + ), function ($result) use ($user, $ability, $arguments) { + $this->dispatchGateEvaluatedEvent($user, $ability, $arguments, $result); + }); } /** @@ -514,6 +581,24 @@ class Gate implements GateContract return $result; } + /** + * Dispatch a gate evaluation event. + * + * @param \Illuminate\Contracts\Auth\Authenticatable|null $user + * @param string $ability + * @param array $arguments + * @param bool|null $result + * @return void + */ + protected function dispatchGateEvaluatedEvent($user, $ability, array $arguments, $result) + { + if ($this->container->bound(Dispatcher::class)) { + $this->container->make(Dispatcher::class)->dispatch( + new Events\GateEvaluated($user, $ability, $result, $arguments) + ); + } + } + /** * Resolve the callable for the given ability and arguments. * @@ -595,7 +680,15 @@ class Gate implements GateContract $classDirname = str_replace('/', '\\', dirname(str_replace('\\', '/', $class))); - return [$classDirname.'\\Policies\\'.class_basename($class).'Policy']; + $classDirnameSegments = explode('\\', $classDirname); + + return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) { + $classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index)); + + return $classDirname.'\\Policies\\'.class_basename($class).'Policy'; + })->reverse()->values()->first(function ($class) { + return class_exists($class); + }) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']); } /** @@ -766,4 +859,17 @@ class Gate implements GateContract { return $this->policies; } + + /** + * Set the container instance used by the gate. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } } diff --git a/src/Illuminate/Auth/AuthManager.php b/src/Illuminate/Auth/AuthManager.php index ebbd7f5f1ac5144bf057c9ce188ca600fe7026d4..b72f391f9e05cdc669aa63833fd8c4d4b9bf5230 100755 --- a/src/Illuminate/Auth/AuthManager.php +++ b/src/Illuminate/Auth/AuthManager.php @@ -122,7 +122,11 @@ class AuthManager implements FactoryContract { $provider = $this->createUserProvider($config['provider'] ?? null); - $guard = new SessionGuard($name, $provider, $this->app['session.store']); + $guard = new SessionGuard( + $name, + $provider, + $this->app['session.store'], + ); // When using the remember me functionality of the authentication services we // will need to be set the encryption instance of the guard, which allows @@ -139,6 +143,10 @@ class AuthManager implements FactoryContract $guard->setRequest($this->app->refresh('request', $guard, 'setRequest')); } + if (isset($config['remember'])) { + $guard->setRememberDuration($config['remember']); + } + return $guard; } @@ -295,6 +303,31 @@ class AuthManager implements FactoryContract return count($this->guards) > 0; } + /** + * Forget all of the resolved guard instances. + * + * @return $this + */ + public function forgetGuards() + { + $this->guards = []; + + return $this; + } + + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + return $this; + } + /** * Dynamically call the default driver instance. * diff --git a/src/Illuminate/Auth/AuthServiceProvider.php b/src/Illuminate/Auth/AuthServiceProvider.php index 7a6b412127849599b0e8df689a72aa17df1750f7..9c17edfa1c6fd8ac0b04dc6085e81a88450603d2 100755 --- a/src/Illuminate/Auth/AuthServiceProvider.php +++ b/src/Illuminate/Auth/AuthServiceProvider.php @@ -35,11 +35,6 @@ class AuthServiceProvider extends ServiceProvider protected function registerAuthenticator() { $this->app->singleton('auth', function ($app) { - // Once the authentication service has actually been requested by the developer - // we will set a variable in the application indicating such. This helps us - // know that we need to set any queued cookies in the after event later. - $app['auth.loaded'] = true; - return new AuthManager($app); }); @@ -55,11 +50,9 @@ class AuthServiceProvider extends ServiceProvider */ protected function registerUserResolver() { - $this->app->bind( - AuthenticatableContract::class, function ($app) { - return call_user_func($app['auth']->userResolver()); - } - ); + $this->app->bind(AuthenticatableContract::class, function ($app) { + return call_user_func($app['auth']->userResolver()); + }); } /** @@ -83,15 +76,13 @@ class AuthServiceProvider extends ServiceProvider */ protected function registerRequirePassword() { - $this->app->bind( - RequirePassword::class, function ($app) { - return new RequirePassword( - $app[ResponseFactory::class], - $app[UrlGenerator::class], - $app['config']->get('auth.password_timeout') - ); - } - ); + $this->app->bind(RequirePassword::class, function ($app) { + return new RequirePassword( + $app[ResponseFactory::class], + $app[UrlGenerator::class], + $app['config']->get('auth.password_timeout') + ); + }); } /** @@ -116,11 +107,8 @@ class AuthServiceProvider extends ServiceProvider protected function registerEventRebindHandler() { $this->app->rebinding('events', function ($app, $dispatcher) { - if (! $app->resolved('auth')) { - return; - } - - if ($app['auth']->hasResolvedGuards() === false) { + if (! $app->resolved('auth') || + $app['auth']->hasResolvedGuards() === false) { return; } diff --git a/src/Illuminate/Auth/Authenticatable.php b/src/Illuminate/Auth/Authenticatable.php index d7578a3dcb1ef589e3b74a2720a800eb8fe18960..f1c0115916c82504f573a971bfacea24852fcdcb 100644 --- a/src/Illuminate/Auth/Authenticatable.php +++ b/src/Illuminate/Auth/Authenticatable.php @@ -31,6 +31,16 @@ trait Authenticatable return $this->{$this->getAuthIdentifierName()}; } + /** + * Get the unique broadcast identifier for the user. + * + * @return mixed + */ + public function getAuthIdentifierForBroadcasting() + { + return $this->getAuthIdentifier(); + } + /** * Get the password for the user. * diff --git a/src/Illuminate/Auth/AuthenticationException.php b/src/Illuminate/Auth/AuthenticationException.php index ef7dbee632c06e98306e2b6218fe65ed7d1c3356..66808c3b8151dbf714734c98107adb9b7e1dbe02 100644 --- a/src/Illuminate/Auth/AuthenticationException.php +++ b/src/Illuminate/Auth/AuthenticationException.php @@ -16,7 +16,7 @@ class AuthenticationException extends Exception /** * The path the user should be redirected to. * - * @var string + * @var string|null */ protected $redirectTo; @@ -49,7 +49,7 @@ class AuthenticationException extends Exception /** * Get the path the user should be redirected to. * - * @return string + * @return string|null */ public function redirectTo() { diff --git a/src/Illuminate/Auth/Console/stubs/make/views/layouts/app.stub b/src/Illuminate/Auth/Console/stubs/make/views/layouts/app.stub index 9224ba3819d42626a890690d5b8fc76e4fb1adc4..d1822366df73bab9ae6e8a3de4f521e7eda68441 100644 --- a/src/Illuminate/Auth/Console/stubs/make/views/layouts/app.stub +++ b/src/Illuminate/Auth/Console/stubs/make/views/layouts/app.stub @@ -13,8 +13,8 @@ <script src="{{ asset('js/app.js') }}" defer></script> <!-- Fonts --> - <link rel="dns-prefetch" href="//fonts.gstatic.com"> - <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet"> + <link rel="preconnect" href="https://fonts.gstatic.com"> + <link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet"> <!-- Styles --> <link href="{{ asset('css/app.css') }}" rel="stylesheet"> diff --git a/src/Illuminate/Auth/DatabaseUserProvider.php b/src/Illuminate/Auth/DatabaseUserProvider.php index 8aa563d82023462803e00626abfaa66f44203985..111e5522563acbc64fa416b7fa71eaea676265cb 100755 --- a/src/Illuminate/Auth/DatabaseUserProvider.php +++ b/src/Illuminate/Auth/DatabaseUserProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Auth; +use Closure; use Illuminate\Contracts\Auth\Authenticatable as UserContract; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Hashing\Hasher as HasherContract; @@ -117,6 +118,8 @@ class DatabaseUserProvider implements UserProvider if (is_array($value) || $value instanceof Arrayable) { $query->whereIn($key, $value); + } elseif ($value instanceof Closure) { + $value($query); } else { $query->where($key, $value); } diff --git a/src/Illuminate/Auth/EloquentUserProvider.php b/src/Illuminate/Auth/EloquentUserProvider.php index f175298ce07a252fe74c48458aa89dcf6d69cccc..54dff6b87f2c46f333ea80d44c53ac4d216bb900 100755 --- a/src/Illuminate/Auth/EloquentUserProvider.php +++ b/src/Illuminate/Auth/EloquentUserProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Auth; +use Closure; use Illuminate\Contracts\Auth\Authenticatable as UserContract; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Hashing\Hasher as HasherContract; @@ -123,6 +124,8 @@ class EloquentUserProvider implements UserProvider if (is_array($value) || $value instanceof Arrayable) { $query->whereIn($key, $value); + } elseif ($value instanceof Closure) { + $value($query); } else { $query->where($key, $value); } diff --git a/src/Illuminate/Auth/GuardHelpers.php b/src/Illuminate/Auth/GuardHelpers.php index fb9267ca1d599524bde8d3dfd6445a19aa42ac0e..aa9ebf9ec64af020df73734cbb23da4251d0d403 100644 --- a/src/Illuminate/Auth/GuardHelpers.php +++ b/src/Illuminate/Auth/GuardHelpers.php @@ -25,7 +25,7 @@ trait GuardHelpers protected $provider; /** - * Determine if current user is authenticated. If not, throw an exception. + * Determine if the current user is authenticated. If not, throw an exception. * * @return \Illuminate\Contracts\Auth\Authenticatable * @@ -73,7 +73,7 @@ trait GuardHelpers /** * Get the ID for the currently authenticated user. * - * @return int|null + * @return int|string|null */ public function id() { diff --git a/src/Illuminate/Auth/Middleware/Authenticate.php b/src/Illuminate/Auth/Middleware/Authenticate.php index ec82e44d6073e39ba8aab9e862da1b634d570220..7eda342d3e01a8b6c02571d05dacfb15a8742b8d 100644 --- a/src/Illuminate/Auth/Middleware/Authenticate.php +++ b/src/Illuminate/Auth/Middleware/Authenticate.php @@ -5,8 +5,9 @@ namespace Illuminate\Auth\Middleware; use Closure; use Illuminate\Auth\AuthenticationException; use Illuminate\Contracts\Auth\Factory as Auth; +use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests; -class Authenticate +class Authenticate implements AuthenticatesRequests { /** * The authentication factory instance. diff --git a/src/Illuminate/Auth/Middleware/EnsureEmailIsVerified.php b/src/Illuminate/Auth/Middleware/EnsureEmailIsVerified.php index 1f73e576ad636031df6d8cd36f02134ad91fbb82..8f2b33ae5c7207daa547304b21312f9e3d5de7d6 100644 --- a/src/Illuminate/Auth/Middleware/EnsureEmailIsVerified.php +++ b/src/Illuminate/Auth/Middleware/EnsureEmailIsVerified.php @@ -5,6 +5,7 @@ namespace Illuminate\Auth\Middleware; use Closure; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\URL; class EnsureEmailIsVerified { @@ -14,7 +15,7 @@ class EnsureEmailIsVerified * @param \Illuminate\Http\Request $request * @param \Closure $next * @param string|null $redirectToRoute - * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse + * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse|null */ public function handle($request, Closure $next, $redirectToRoute = null) { @@ -23,7 +24,7 @@ class EnsureEmailIsVerified ! $request->user()->hasVerifiedEmail())) { return $request->expectsJson() ? abort(403, 'Your email address is not verified.') - : Redirect::route($redirectToRoute ?: 'verification.notice'); + : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice')); } return $next($request); diff --git a/src/Illuminate/Auth/Notifications/ResetPassword.php b/src/Illuminate/Auth/Notifications/ResetPassword.php index 2d46c8a4499dd8e2ab4696b8bf462edbfffaa8bf..f1c13f4d720e59810762dcb553249202d792fa01 100644 --- a/src/Illuminate/Auth/Notifications/ResetPassword.php +++ b/src/Illuminate/Auth/Notifications/ResetPassword.php @@ -15,6 +15,13 @@ class ResetPassword extends Notification */ public $token; + /** + * The callback that should be used to create the reset password URL. + * + * @var \Closure|null + */ + public static $createUrlCallback; + /** * The callback that should be used to build the mail message. * @@ -56,14 +63,54 @@ class ResetPassword extends Notification return call_user_func(static::$toMailCallback, $notifiable, $this->token); } + return $this->buildMailMessage($this->resetUrl($notifiable)); + } + + /** + * Get the reset password notification mail message for the given URL. + * + * @param string $url + * @return \Illuminate\Notifications\Messages\MailMessage + */ + protected function buildMailMessage($url) + { return (new MailMessage) ->subject(Lang::get('Reset Password Notification')) ->line(Lang::get('You are receiving this email because we received a password reset request for your account.')) - ->action(Lang::get('Reset Password'), url(route('password.reset', ['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()], false))) + ->action(Lang::get('Reset Password'), $url) ->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')])) ->line(Lang::get('If you did not request a password reset, no further action is required.')); } + /** + * Get the reset URL for the given notifiable. + * + * @param mixed $notifiable + * @return string + */ + protected function resetUrl($notifiable) + { + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable, $this->token); + } + + return url(route('password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ], false)); + } + + /** + * Set a callback that should be used when creating the reset password button URL. + * + * @param \Closure $callback + * @return void + */ + public static function createUrlUsing($callback) + { + static::$createUrlCallback = $callback; + } + /** * Set a callback that should be used when building the notification mail message. * diff --git a/src/Illuminate/Auth/Notifications/VerifyEmail.php b/src/Illuminate/Auth/Notifications/VerifyEmail.php index f746685fc44ae050faab324b5f75940530c94d3d..7a5cf916449d8c1f0efe36cd4ab01682552a41b5 100644 --- a/src/Illuminate/Auth/Notifications/VerifyEmail.php +++ b/src/Illuminate/Auth/Notifications/VerifyEmail.php @@ -11,6 +11,13 @@ use Illuminate\Support\Facades\URL; class VerifyEmail extends Notification { + /** + * The callback that should be used to create the verify email URL. + * + * @var \Closure|null + */ + public static $createUrlCallback; + /** * The callback that should be used to build the mail message. * @@ -43,10 +50,21 @@ class VerifyEmail extends Notification return call_user_func(static::$toMailCallback, $notifiable, $verificationUrl); } + return $this->buildMailMessage($verificationUrl); + } + + /** + * Get the verify email notification mail message for the given URL. + * + * @param string $url + * @return \Illuminate\Notifications\Messages\MailMessage + */ + protected function buildMailMessage($url) + { return (new MailMessage) ->subject(Lang::get('Verify Email Address')) ->line(Lang::get('Please click the button below to verify your email address.')) - ->action(Lang::get('Verify Email Address'), $verificationUrl) + ->action(Lang::get('Verify Email Address'), $url) ->line(Lang::get('If you did not create an account, no further action is required.')); } @@ -58,6 +76,10 @@ class VerifyEmail extends Notification */ protected function verificationUrl($notifiable) { + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable); + } + return URL::temporarySignedRoute( 'verification.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), @@ -68,6 +90,17 @@ class VerifyEmail extends Notification ); } + /** + * Set a callback that should be used when creating the email verification URL. + * + * @param \Closure $callback + * @return void + */ + public static function createUrlUsing($callback) + { + static::$createUrlCallback = $callback; + } + /** * Set a callback that should be used when building the notification mail message. * diff --git a/src/Illuminate/Auth/Passwords/PasswordBroker.php b/src/Illuminate/Auth/Passwords/PasswordBroker.php index 30861dc40d5388c1184375144254e8a924cddb25..cbbc897abd85bdc1044603eb7e5b081afdc67bb8 100755 --- a/src/Illuminate/Auth/Passwords/PasswordBroker.php +++ b/src/Illuminate/Auth/Passwords/PasswordBroker.php @@ -42,9 +42,10 @@ class PasswordBroker implements PasswordBrokerContract * Send a password reset link to a user. * * @param array $credentials + * @param \Closure|null $callback * @return string */ - public function sendResetLink(array $credentials) + public function sendResetLink(array $credentials, Closure $callback = null) { // First we will check to see if we found a user at the given credentials and // if we did not we will redirect back to this current URI with a piece of @@ -55,17 +56,20 @@ class PasswordBroker implements PasswordBrokerContract return static::INVALID_USER; } - if (method_exists($this->tokens, 'recentlyCreatedToken') && - $this->tokens->recentlyCreatedToken($user)) { + if ($this->tokens->recentlyCreatedToken($user)) { return static::RESET_THROTTLED; } - // Once we have the reset token, we are ready to send the message out to this - // user with a link to reset their password. We will then redirect back to - // the current URI having nothing set in the session to indicate errors. - $user->sendPasswordResetNotification( - $this->tokens->create($user) - ); + $token = $this->tokens->create($user); + + if ($callback) { + $callback($user, $token); + } else { + // Once we have the reset token, we are ready to send the message out to this + // user with a link to reset their password. We will then redirect back to + // the current URI having nothing set in the session to indicate errors. + $user->sendPasswordResetNotification($token); + } return static::RESET_LINK_SENT; } diff --git a/src/Illuminate/Auth/Passwords/TokenRepositoryInterface.php b/src/Illuminate/Auth/Passwords/TokenRepositoryInterface.php index dcd06e8d65169a23f17214e461b82ba0b77870e8..47c17581ff50e7cc140d1bbe58d118b87193e59f 100755 --- a/src/Illuminate/Auth/Passwords/TokenRepositoryInterface.php +++ b/src/Illuminate/Auth/Passwords/TokenRepositoryInterface.php @@ -23,6 +23,14 @@ interface TokenRepositoryInterface */ public function exists(CanResetPasswordContract $user, $token); + /** + * Determine if the given user recently created a password reset token. + * + * @param \Illuminate\Contracts\Auth\CanResetPassword $user + * @return bool + */ + public function recentlyCreatedToken(CanResetPasswordContract $user); + /** * Delete a token record. * diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 8cc646ec6b9d3ccc1394a280609f7878edea34a6..cd9ec98d7e2fabbe2fbf886900e4f26e7ea6b087 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -17,9 +17,12 @@ use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Cookie\QueueingFactory as CookieJar; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Session\Session; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Illuminate\Support\Timebox; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; @@ -29,7 +32,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth use GuardHelpers, Macroable; /** - * The name of the Guard. Typically "session". + * The name of the guard. Typically "web". * * Corresponds to guard name in authentication configuration. * @@ -51,6 +54,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected $viaRemember = false; + /** + * The number of minutes that the "remember me" cookie should be valid for. + * + * @var int + */ + protected $rememberDuration = 2628000; + /** * The session used by the guard. * @@ -79,6 +89,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected $events; + /** + * The timebox instance. + * + * @var \Illuminate\Support\Timebox + */ + protected $timebox; + /** * Indicates if the logout method has been called. * @@ -100,17 +117,20 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth * @param \Illuminate\Contracts\Auth\UserProvider $provider * @param \Illuminate\Contracts\Session\Session $session * @param \Symfony\Component\HttpFoundation\Request|null $request + * @param \Illuminate\Support\Timebox|null $timebox * @return void */ public function __construct($name, UserProvider $provider, Session $session, - Request $request = null) + Request $request = null, + Timebox $timebox = null) { $this->name = $name; $this->session = $session; $this->request = $request; $this->provider = $provider; + $this->timebox = $timebox ?: new Timebox; } /** @@ -199,7 +219,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth /** * Get the ID for the currently authenticated user. * - * @return int|null + * @return int|string|null */ public function id() { @@ -320,7 +340,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth } /** - * Get the credential array for a HTTP Basic request. + * Get the credential array for an HTTP Basic request. * * @param \Symfony\Component\HttpFoundation\Request $request * @param string $field @@ -373,6 +393,34 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth return false; } + /** + * Attempt to authenticate a user with credentials and additional callbacks. + * + * @param array $credentials + * @param array|callable $callbacks + * @param false $remember + * @return bool + */ + public function attemptWhen(array $credentials = [], $callbacks = null, $remember = false) + { + $this->fireAttemptEvent($credentials, $remember); + + $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials); + + // This method does the exact same thing as attempt, but also executes callbacks after + // the user is retrieved and validated. If one of the callbacks returns falsy we do + // not login the user. Instead, we will fail the specific authentication attempt. + if ($this->hasValidCredentials($user, $credentials) && $this->shouldLogin($callbacks, $user)) { + $this->login($user, $remember); + + return true; + } + + $this->fireFailedEvent($user, $credentials); + + return false; + } + /** * Determine if the user matches the credentials. * @@ -382,13 +430,35 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected function hasValidCredentials($user, $credentials) { - $validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials); + return $this->timebox->call(function ($timebox) use ($user, $credentials) { + $validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials); + + if ($validated) { + $timebox->returnEarly(); + + $this->fireValidatedEvent($user); + } + + return $validated; + }, 200 * 1000); + } - if ($validated) { - $this->fireValidatedEvent($user); + /** + * Determine if the user should login by executing the given callbacks. + * + * @param array|callable|null $callbacks + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return bool + */ + protected function shouldLogin($callbacks, AuthenticatableContract $user) + { + foreach (Arr::wrap($callbacks) as $callback) { + if (! $callback($user, $this)) { + return false; + } } - return $validated; + return true; } /** @@ -484,7 +554,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth */ protected function createRecaller($value) { - return $this->getCookieJar()->forever($this->getRecallerName(), $value); + return $this->getCookieJar()->make($this->getRecallerName(), $value, $this->getRememberDuration()); } /** @@ -517,6 +587,34 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth $this->loggedOut = true; } + /** + * Log the user out of the application on their current device only. + * + * This method does not cycle the "remember" token. + * + * @return void + */ + public function logoutCurrentDevice() + { + $user = $this->user(); + + $this->clearUserDataFromStorage(); + + // If we have an event dispatcher instance, we can fire off the logout event + // so any further processing can be done. This allows the developer to be + // listening for anytime a user signs out of this application manually. + if (isset($this->events)) { + $this->events->dispatch(new CurrentDeviceLogout($this->name, $user)); + } + + // Once we have fired the logout event we will clear the users out of memory + // so they are no longer available as the user is no longer considered as + // being signed into this application and should not be available here. + $this->user = null; + + $this->loggedOut = true; + } + /** * Remove the user data from the session and cookies. * @@ -545,32 +643,6 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth $this->provider->updateRememberToken($user, $token); } - /** - * Log the user out of the application on their current device only. - * - * @return void - */ - public function logoutCurrentDevice() - { - $user = $this->user(); - - $this->clearUserDataFromStorage(); - - // If we have an event dispatcher instance, we can fire off the logout event - // so any further processing can be done. This allows the developer to be - // listening for anytime a user signs out of this application manually. - if (isset($this->events)) { - $this->events->dispatch(new CurrentDeviceLogout($this->name, $user)); - } - - // Once we have fired the logout event we will clear the users out of memory - // so they are no longer available as the user is no longer considered as - // being signed into this application and should not be available here. - $this->user = null; - - $this->loggedOut = true; - } - /** * Invalidate other sessions for the current user. * @@ -578,7 +650,9 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth * * @param string $password * @param string $attribute - * @return bool|null + * @return \Illuminate\Contracts\Auth\Authenticatable|null + * + * @throws \Illuminate\Auth\AuthenticationException */ public function logoutOtherDevices($password, $attribute = 'password') { @@ -586,9 +660,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth return; } - $result = tap($this->user()->forceFill([ - $attribute => Hash::make($password), - ]))->save(); + $result = $this->rehashUserPassword($password, $attribute); if ($this->recaller() || $this->getCookieJar()->hasQueued($this->getRecallerName())) { @@ -600,6 +672,26 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth return $result; } + /** + * Rehash the current user's password. + * + * @param string $password + * @param string $attribute + * @return \Illuminate\Contracts\Auth\Authenticatable|null + * + * @throws \InvalidArgumentException + */ + protected function rehashUserPassword($password, $attribute) + { + if (! Hash::check($password, $this->user()->{$attribute})) { + throw new InvalidArgumentException('The given password does not match the current password.'); + } + + return tap($this->user()->forceFill([ + $attribute => Hash::make($password), + ]))->save(); + } + /** * Register an authentication attempt event listener. * @@ -632,7 +724,8 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth /** * Fires the validated event if the dispatcher is set. * - * @param $user + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return void */ protected function fireValidatedEvent($user) { @@ -745,6 +838,29 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth return $this->viaRemember; } + /** + * Get the number of minutes the remember me cookie should be valid for. + * + * @return int + */ + protected function getRememberDuration() + { + return $this->rememberDuration; + } + + /** + * Set the number of minutes the remember me cookie should be valid for. + * + * @param int $minutes + * @return $this + */ + public function setRememberDuration($minutes) + { + $this->rememberDuration = $minutes; + + return $this; + } + /** * Get the cookie creator instance used by the guard. * @@ -852,4 +968,14 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth return $this; } + + /** + * Get the timebox instance used by the guard. + * + * @return \Illuminate\Support\Timebox + */ + public function getTimebox() + { + return $this->timebox; + } } diff --git a/src/Illuminate/Auth/composer.json b/src/Illuminate/Auth/composer.json index dacafaaac9ec92425846f2ef8666572bd8e8d423..842066cdef12d79e31595b0449013d802bb33bfd 100644 --- a/src/Illuminate/Auth/composer.json +++ b/src/Illuminate/Auth/composer.json @@ -14,11 +14,13 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/http": "^6.0", - "illuminate/queue": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/http": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/queue": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -27,13 +29,13 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/console": "Required to use the auth:clear-resets command (^6.0).", - "illuminate/queue": "Required to fire login / logout events (^6.0).", - "illuminate/session": "Required to use the session based guard (^6.0)." + "illuminate/console": "Required to use the auth:clear-resets command (^8.0).", + "illuminate/queue": "Required to fire login / logout events (^8.0).", + "illuminate/session": "Required to use the session based guard (^8.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Broadcasting/BroadcastEvent.php b/src/Illuminate/Broadcasting/BroadcastEvent.php index 775df78059d710a0516313e68e3ef65cffac19b2..24a1c33676136b9ee2935ce4c9902c89be8ed99d 100644 --- a/src/Illuminate/Broadcasting/BroadcastEvent.php +++ b/src/Illuminate/Broadcasting/BroadcastEvent.php @@ -3,7 +3,7 @@ namespace Illuminate\Broadcasting; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Broadcasting\Broadcaster; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; @@ -46,23 +46,37 @@ class BroadcastEvent implements ShouldQueue $this->event = $event; $this->tries = property_exists($event, 'tries') ? $event->tries : null; $this->timeout = property_exists($event, 'timeout') ? $event->timeout : null; + $this->afterCommit = property_exists($event, 'afterCommit') ? $event->afterCommit : null; } /** * Handle the queued job. * - * @param \Illuminate\Contracts\Broadcasting\Broadcaster $broadcaster + * @param \Illuminate\Contracts\Broadcasting\Factory $manager * @return void */ - public function handle(Broadcaster $broadcaster) + public function handle(BroadcastingFactory $manager) { $name = method_exists($this->event, 'broadcastAs') ? $this->event->broadcastAs() : get_class($this->event); - $broadcaster->broadcast( - Arr::wrap($this->event->broadcastOn()), $name, - $this->getPayloadFromEvent($this->event) - ); + $channels = Arr::wrap($this->event->broadcastOn()); + + if (empty($channels)) { + return; + } + + $connections = method_exists($this->event, 'broadcastConnections') + ? $this->event->broadcastConnections() + : [null]; + + $payload = $this->getPayloadFromEvent($this->event); + + foreach ($connections as $connection) { + $manager->connection($connection)->broadcast( + $channels, $name, $payload + ); + } } /** @@ -73,10 +87,9 @@ class BroadcastEvent implements ShouldQueue */ protected function getPayloadFromEvent($event) { - if (method_exists($event, 'broadcastWith')) { - return array_merge( - $event->broadcastWith(), ['socket' => data_get($event, 'socket')] - ); + if (method_exists($event, 'broadcastWith') && + ! is_null($payload = $event->broadcastWith())) { + return array_merge($payload, ['socket' => data_get($event, 'socket')]); } $payload = []; diff --git a/src/Illuminate/Broadcasting/BroadcastManager.php b/src/Illuminate/Broadcasting/BroadcastManager.php index c6d72e4498000114411d62f5ae04dcbe681c6c19..a4957cde900f5e3a1dfac1167d1b9c1e53abf436 100644 --- a/src/Illuminate/Broadcasting/BroadcastManager.php +++ b/src/Illuminate/Broadcasting/BroadcastManager.php @@ -2,7 +2,9 @@ namespace Illuminate\Broadcasting; +use Ably\AblyRest; use Closure; +use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster; use Illuminate\Broadcasting\Broadcasters\LogBroadcaster; use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; @@ -10,6 +12,7 @@ use Illuminate\Broadcasting\Broadcasters\RedisBroadcaster; use Illuminate\Contracts\Broadcasting\Factory as FactoryContract; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Illuminate\Contracts\Foundation\CachesRoutes; use InvalidArgumentException; use Psr\Log\LoggerInterface; use Pusher\Pusher; @@ -22,7 +25,7 @@ class BroadcastManager implements FactoryContract /** * The application instance. * - * @var \Illuminate\Contracts\Foundation\Application + * @var \Illuminate\Contracts\Container\Container */ protected $app; @@ -43,7 +46,7 @@ class BroadcastManager implements FactoryContract /** * Create a new manager instance. * - * @param \Illuminate\Contracts\Foundation\Application $app + * @param \Illuminate\Contracts\Container\Container $app * @return void */ public function __construct($app) @@ -59,7 +62,7 @@ class BroadcastManager implements FactoryContract */ public function routes(array $attributes = null) { - if ($this->app->routesAreCached()) { + if ($this->app instanceof CachesRoutes && $this->app->routesAreCached()) { return; } @@ -69,7 +72,7 @@ class BroadcastManager implements FactoryContract $router->match( ['get', 'post'], '/broadcasting/auth', '\\'.BroadcastController::class.'@authenticate' - ); + )->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]); }); } @@ -109,7 +112,10 @@ class BroadcastManager implements FactoryContract */ public function queue($event) { - if ($event instanceof ShouldBroadcastNow) { + if ($event instanceof ShouldBroadcastNow || + (is_object($event) && + method_exists($event, 'shouldBroadcastNow') && + $event->shouldBroadcastNow())) { return $this->app->make(BusDispatcherContract::class)->dispatchNow(new BroadcastEvent(clone $event)); } @@ -219,6 +225,17 @@ class BroadcastManager implements FactoryContract return new PusherBroadcaster($pusher); } + /** + * Create an instance of the driver. + * + * @param array $config + * @return \Illuminate\Contracts\Broadcasting\Broadcaster + */ + protected function createAblyDriver(array $config) + { + return new AblyBroadcaster(new AblyRest($config)); + } + /** * Create an instance of the driver. * @@ -293,6 +310,19 @@ class BroadcastManager implements FactoryContract $this->app['config']['broadcasting.default'] = $name; } + /** + * Disconnect the given disk and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?? $this->getDefaultDriver(); + + unset($this->drivers[$name]); + } + /** * Register a custom driver creator Closure. * @@ -307,6 +337,41 @@ class BroadcastManager implements FactoryContract return $this; } + /** + * Get the application instance used by the manager. + * + * @return \Illuminate\Contracts\Foundation\Application + */ + public function getApplication() + { + return $this->app; + } + + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + return $this; + } + + /** + * Forget all of the resolved driver instances. + * + * @return $this + */ + public function forgetDrivers() + { + $this->drivers = []; + + return $this; + } + /** * Dynamically call the default driver instance. * diff --git a/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php new file mode 100644 index 0000000000000000000000000000000000000000..ead68a1842c77e61c272eb77a259da8243fe1970 --- /dev/null +++ b/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php @@ -0,0 +1,225 @@ +<?php + +namespace Illuminate\Broadcasting\Broadcasters; + +use Ably\AblyRest; +use Ably\Models\Message as AblyMessage; +use Illuminate\Support\Str; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +/** + * @author Matthew Hall (matthall28@gmail.com) + * @author Taylor Otwell (taylor@laravel.com) + */ +class AblyBroadcaster extends Broadcaster +{ + /** + * The AblyRest SDK instance. + * + * @var \Ably\AblyRest + */ + protected $ably; + + /** + * Create a new broadcaster instance. + * + * @param \Ably\AblyRest $ably + * @return void + */ + public function __construct(AblyRest $ably) + { + $this->ably = $ably; + } + + /** + * Authenticate the incoming request for a given channel. + * + * @param \Illuminate\Http\Request $request + * @return mixed + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + public function auth($request) + { + $channelName = $this->normalizeChannelName($request->channel_name); + + if (empty($request->channel_name) || + ($this->isGuardedChannel($request->channel_name) && + ! $this->retrieveUser($request, $channelName))) { + throw new AccessDeniedHttpException; + } + + return parent::verifyUserCanAccessChannel( + $request, $channelName + ); + } + + /** + * Return the valid authentication response. + * + * @param \Illuminate\Http\Request $request + * @param mixed $result + * @return mixed + */ + public function validAuthenticationResponse($request, $result) + { + if (Str::startsWith($request->channel_name, 'private')) { + $signature = $this->generateAblySignature( + $request->channel_name, $request->socket_id + ); + + return ['auth' => $this->getPublicToken().':'.$signature]; + } + + $channelName = $this->normalizeChannelName($request->channel_name); + + $user = $this->retrieveUser($request, $channelName); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + + $signature = $this->generateAblySignature( + $request->channel_name, + $request->socket_id, + $userData = array_filter([ + 'user_id' => (string) $broadcastIdentifier, + 'user_info' => $result, + ]) + ); + + return [ + 'auth' => $this->getPublicToken().':'.$signature, + 'channel_data' => json_encode($userData), + ]; + } + + /** + * Generate the signature needed for Ably authentication headers. + * + * @param string $channelName + * @param string $socketId + * @param array|null $userData + * @return string + */ + public function generateAblySignature($channelName, $socketId, $userData = null) + { + return hash_hmac( + 'sha256', + sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':'.json_encode($userData) : ''), + $this->getPrivateToken(), + ); + } + + /** + * Broadcast the given event. + * + * @param array $channels + * @param string $event + * @param array $payload + * @return void + */ + public function broadcast(array $channels, $event, array $payload = []) + { + foreach ($this->formatChannels($channels) as $channel) { + $this->ably->channels->get($channel)->publish( + $this->buildAblyMessage($event, $payload) + ); + } + } + + /** + * Build an Ably message object for broadcasting. + * + * @param string $event + * @param array $payload + * @return \Ably\Models\Message + */ + protected function buildAblyMessage($event, array $payload = []) + { + return tap(new AblyMessage, function ($message) use ($event, $payload) { + $message->name = $event; + $message->data = $payload; + $message->connectionKey = data_get($payload, 'socket'); + }); + } + + /** + * Return true if the channel is protected by authentication. + * + * @param string $channel + * @return bool + */ + public function isGuardedChannel($channel) + { + return Str::startsWith($channel, ['private-', 'presence-']); + } + + /** + * Remove prefix from channel name. + * + * @param string $channel + * @return string + */ + public function normalizeChannelName($channel) + { + if ($this->isGuardedChannel($channel)) { + return Str::startsWith($channel, 'private-') + ? Str::replaceFirst('private-', '', $channel) + : Str::replaceFirst('presence-', '', $channel); + } + + return $channel; + } + + /** + * Format the channel array into an array of strings. + * + * @param array $channels + * @return array + */ + protected function formatChannels(array $channels) + { + return array_map(function ($channel) { + $channel = (string) $channel; + + if (Str::startsWith($channel, ['private-', 'presence-'])) { + return Str::startsWith($channel, 'private-') + ? Str::replaceFirst('private-', 'private:', $channel) + : Str::replaceFirst('presence-', 'presence:', $channel); + } + + return 'public:'.$channel; + }, $channels); + } + + /** + * Get the public token value from the Ably key. + * + * @return mixed + */ + protected function getPublicToken() + { + return Str::before($this->ably->options->key, ':'); + } + + /** + * Get the private token value from the Ably key. + * + * @return mixed + */ + protected function getPrivateToken() + { + return Str::after($this->ably->options->key, ':'); + } + + /** + * Get the underlying Ably SDK instance. + * + * @return \Ably\AblyRest + */ + public function getAbly() + { + return $this->ably; + } +} diff --git a/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php index f626187b069b44413b8b03c2ab29ddd423804c84..a25b2ff2f678736183214fb364ff844720929c32 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php +++ b/src/Illuminate/Broadcasting/Broadcasters/Broadcaster.php @@ -5,6 +5,7 @@ namespace Illuminate\Broadcasting\Broadcasters; use Exception; use Illuminate\Container\Container; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; +use Illuminate\Contracts\Broadcasting\HasBroadcastChannel; use Illuminate\Contracts\Routing\BindingRegistrar; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Support\Arr; @@ -40,13 +41,19 @@ abstract class Broadcaster implements BroadcasterContract /** * Register a channel authenticator. * - * @param string $channel + * @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $channel * @param callable|string $callback * @param array $options * @return $this */ public function channel($channel, $callback, $options = []) { + if ($channel instanceof HasBroadcastChannel) { + $channel = $channel->broadcastChannelRoute(); + } elseif (is_string($channel) && class_exists($channel) && is_a($channel, HasBroadcastChannel::class, true)) { + $channel = (new $channel)->broadcastChannelRoute(); + } + $this->channels[$channel] = $callback; $this->channelOptions[$channel] = $options; @@ -262,7 +269,7 @@ abstract class Broadcaster implements BroadcasterContract * Normalize the given callback into a callable. * * @param mixed $callback - * @return \Closure|callable + * @return callable */ protected function normalizeChannelHandlerToCallable($callback) { @@ -317,7 +324,7 @@ abstract class Broadcaster implements BroadcasterContract } /** - * Check if channel name from request match a pattern from registered channels. + * Check if the channel name from the request matches a pattern from registered channels. * * @param string $channel * @param string $pattern diff --git a/src/Illuminate/Broadcasting/Broadcasters/PusherBroadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/PusherBroadcaster.php index c39abbd6f61b77310bddc18b0f0325f2ee2767cb..6b41bc740e9b5ac1c00929250b02ae64550799f7 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/PusherBroadcaster.php +++ b/src/Illuminate/Broadcasting/Broadcasters/PusherBroadcaster.php @@ -5,6 +5,7 @@ namespace Illuminate\Broadcasting\Broadcasters; use Illuminate\Broadcasting\BroadcastException; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Pusher\ApiErrorException; use Pusher\Pusher; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -42,8 +43,9 @@ class PusherBroadcaster extends Broadcaster { $channelName = $this->normalizeChannelName($request->channel_name); - if ($this->isGuardedChannel($request->channel_name) && - ! $this->retrieveUser($request, $channelName)) { + if (empty($request->channel_name) || + ($this->isGuardedChannel($request->channel_name) && + ! $this->retrieveUser($request, $channelName))) { throw new AccessDeniedHttpException; } @@ -69,11 +71,17 @@ class PusherBroadcaster extends Broadcaster $channelName = $this->normalizeChannelName($request->channel_name); + $user = $this->retrieveUser($request, $channelName); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + return $this->decodePusherResponse( $request, $this->pusher->presence_auth( $request->channel_name, $request->socket_id, - $this->retrieveUser($request, $channelName)->getAuthIdentifier(), $result + $broadcastIdentifier, $result ) ); } @@ -109,20 +117,44 @@ class PusherBroadcaster extends Broadcaster { $socket = Arr::pull($payload, 'socket'); - $response = $this->pusher->trigger( - $this->formatChannels($channels), $event, $payload, $socket, true - ); + if ($this->pusherServerIsVersionFiveOrGreater()) { + $parameters = $socket !== null ? ['socket_id' => $socket] : []; + + try { + $this->pusher->trigger( + $this->formatChannels($channels), $event, $payload, $parameters + ); + } catch (ApiErrorException $e) { + throw new BroadcastException( + sprintf('Pusher error: %s.', $e->getMessage()) + ); + } + } else { + $response = $this->pusher->trigger( + $this->formatChannels($channels), $event, $payload, $socket, true + ); + + if ((is_array($response) && $response['status'] >= 200 && $response['status'] <= 299) + || $response === true) { + return; + } - if ((is_array($response) && $response['status'] >= 200 && $response['status'] <= 299) - || $response === true) { - return; + throw new BroadcastException( + ! empty($response['body']) + ? sprintf('Pusher error: %s.', $response['body']) + : 'Failed to connect to Pusher.' + ); } + } - throw new BroadcastException( - ! empty($response['body']) - ? sprintf('Pusher error: %s.', $response['body']) - : 'Failed to connect to Pusher.' - ); + /** + * Determine if the Pusher PHP server is version 5.0 or greater. + * + * @return bool + */ + protected function pusherServerIsVersionFiveOrGreater() + { + return class_exists(ApiErrorException::class); } /** @@ -134,4 +166,15 @@ class PusherBroadcaster extends Broadcaster { return $this->pusher; } + + /** + * Set the Pusher SDK instance. + * + * @param \Pusher\Pusher $pusher + * @return void + */ + public function setPusher($pusher) + { + $this->pusher = $pusher; + } } diff --git a/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php index 18cb0fef3cdbb883c40a08209d65dadc7cf022b6..2c2dc88c3f765a9a671de94e05c82807c45a5be6 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php +++ b/src/Illuminate/Broadcasting/Broadcasters/RedisBroadcaster.php @@ -20,16 +20,16 @@ class RedisBroadcaster extends Broadcaster /** * The Redis connection to use for broadcasting. * - * @var string + * @var ?string */ - protected $connection; + protected $connection = null; /** * The Redis key prefix. * * @var string */ - protected $prefix; + protected $prefix = ''; /** * Create a new broadcaster instance. @@ -60,8 +60,9 @@ class RedisBroadcaster extends Broadcaster str_replace($this->prefix, '', $request->channel_name) ); - if ($this->isGuardedChannel($request->channel_name) && - ! $this->retrieveUser($request, $channelName)) { + if (empty($request->channel_name) || + ($this->isGuardedChannel($request->channel_name) && + ! $this->retrieveUser($request, $channelName))) { throw new AccessDeniedHttpException; } @@ -85,8 +86,14 @@ class RedisBroadcaster extends Broadcaster $channelName = $this->normalizeChannelName($request->channel_name); + $user = $this->retrieveUser($request, $channelName); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + return json_encode(['channel_data' => [ - 'user_id' => $this->retrieveUser($request, $channelName)->getAuthIdentifier(), + 'user_id' => $broadcastIdentifier, 'user_info' => $result, ]]); } diff --git a/src/Illuminate/Broadcasting/Broadcasters/UsePusherChannelConventions.php b/src/Illuminate/Broadcasting/Broadcasters/UsePusherChannelConventions.php index 07c707ceb046f7db2a39fec5a3f73b4718274eb1..690cf3d4aca2e63da927dc9ec74cb9d69900a365 100644 --- a/src/Illuminate/Broadcasting/Broadcasters/UsePusherChannelConventions.php +++ b/src/Illuminate/Broadcasting/Broadcasters/UsePusherChannelConventions.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; trait UsePusherChannelConventions { /** - * Return true if channel is protected by authentication. + * Return true if the channel is protected by authentication. * * @param string $channel * @return bool diff --git a/src/Illuminate/Broadcasting/Channel.php b/src/Illuminate/Broadcasting/Channel.php index 798d6026a5f9655ee4c737d8e17efd1bba7a1e9f..02b1a5caaa9ab561e5812c6b656efc12b48e98d3 100644 --- a/src/Illuminate/Broadcasting/Channel.php +++ b/src/Illuminate/Broadcasting/Channel.php @@ -2,6 +2,8 @@ namespace Illuminate\Broadcasting; +use Illuminate\Contracts\Broadcasting\HasBroadcastChannel; + class Channel { /** @@ -14,12 +16,12 @@ class Channel /** * Create a new channel instance. * - * @param string $name + * @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $name * @return void */ public function __construct($name) { - $this->name = $name; + $this->name = $name instanceof HasBroadcastChannel ? $name->broadcastChannel() : $name; } /** diff --git a/src/Illuminate/Broadcasting/InteractsWithBroadcasting.php b/src/Illuminate/Broadcasting/InteractsWithBroadcasting.php new file mode 100644 index 0000000000000000000000000000000000000000..fd27a8cabb67451ca98c9c0a9a1a66f0d1aa5553 --- /dev/null +++ b/src/Illuminate/Broadcasting/InteractsWithBroadcasting.php @@ -0,0 +1,40 @@ +<?php + +namespace Illuminate\Broadcasting; + +use Illuminate\Support\Arr; + +trait InteractsWithBroadcasting +{ + /** + * The broadcaster connection to use to broadcast the event. + * + * @var array + */ + protected $broadcastConnection = [null]; + + /** + * Broadcast the event using a specific broadcaster. + * + * @param array|string|null $connection + * @return $this + */ + public function broadcastVia($connection = null) + { + $this->broadcastConnection = is_null($connection) + ? [null] + : Arr::wrap($connection); + + return $this; + } + + /** + * Get the broadcaster connections the event should be broadcast on. + * + * @return array + */ + public function broadcastConnections() + { + return $this->broadcastConnection; + } +} diff --git a/src/Illuminate/Broadcasting/PendingBroadcast.php b/src/Illuminate/Broadcasting/PendingBroadcast.php index b7550290240d164f87d07789233734ecca4a1ea7..191b905f59380f81f96313c9afb125a2663cce8d 100644 --- a/src/Illuminate/Broadcasting/PendingBroadcast.php +++ b/src/Illuminate/Broadcasting/PendingBroadcast.php @@ -33,6 +33,21 @@ class PendingBroadcast $this->events = $events; } + /** + * Broadcast the event using a specific broadcaster. + * + * @param string|null $connection + * @return $this + */ + public function via($connection = null) + { + if (method_exists($this->event, 'broadcastVia')) { + $this->event->broadcastVia($connection); + } + + return $this; + } + /** * Broadcast the event to everyone except the current user. * diff --git a/src/Illuminate/Broadcasting/PrivateChannel.php b/src/Illuminate/Broadcasting/PrivateChannel.php index 045e630b178f4eaa980fa72e898f812c4a7a9b3b..e53094b25c3fde113b733e7ca7878a34c05d298d 100644 --- a/src/Illuminate/Broadcasting/PrivateChannel.php +++ b/src/Illuminate/Broadcasting/PrivateChannel.php @@ -2,16 +2,20 @@ namespace Illuminate\Broadcasting; +use Illuminate\Contracts\Broadcasting\HasBroadcastChannel; + class PrivateChannel extends Channel { /** * Create a new channel instance. * - * @param string $name + * @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $name * @return void */ public function __construct($name) { + $name = $name instanceof HasBroadcastChannel ? $name->broadcastChannel() : $name; + parent::__construct('private-'.$name); } } diff --git a/src/Illuminate/Broadcasting/composer.json b/src/Illuminate/Broadcasting/composer.json index 9dbf3948599122ae584bada02c3e5f82757c67aa..42852c2fa8eea17615cf98eaa8c3b3026fb3beb7 100644 --- a/src/Illuminate/Broadcasting/composer.json +++ b/src/Illuminate/Broadcasting/composer.json @@ -14,13 +14,14 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "psr/log": "^1.0", - "illuminate/bus": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/queue": "^6.0", - "illuminate/support": "^6.0" + "psr/log": "^1.0|^2.0", + "illuminate/bus": "^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/queue": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -29,11 +30,12 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0)." + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Bus/Batch.php b/src/Illuminate/Bus/Batch.php new file mode 100644 index 0000000000000000000000000000000000000000..d1464e4425798e2563c49fefe6cd271c2915a7cd --- /dev/null +++ b/src/Illuminate/Bus/Batch.php @@ -0,0 +1,481 @@ +<?php + +namespace Illuminate\Bus; + +use Carbon\CarbonImmutable; +use Closure; +use Illuminate\Contracts\Queue\Factory as QueueFactory; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Queue\CallQueuedClosure; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use JsonSerializable; +use Throwable; + +class Batch implements Arrayable, JsonSerializable +{ + /** + * The queue factory implementation. + * + * @var \Illuminate\Contracts\Queue\Factory + */ + protected $queue; + + /** + * The repository implementation. + * + * @var \Illuminate\Bus\BatchRepository + */ + protected $repository; + + /** + * The batch ID. + * + * @var string + */ + public $id; + + /** + * The batch name. + * + * @var string + */ + public $name; + + /** + * The total number of jobs that belong to the batch. + * + * @var int + */ + public $totalJobs; + + /** + * The total number of jobs that are still pending. + * + * @var int + */ + public $pendingJobs; + + /** + * The total number of jobs that have failed. + * + * @var int + */ + public $failedJobs; + + /** + * The IDs of the jobs that have failed. + * + * @var array + */ + public $failedJobIds; + + /** + * The batch options. + * + * @var array + */ + public $options; + + /** + * The date indicating when the batch was created. + * + * @var \Carbon\CarbonImmutable + */ + public $createdAt; + + /** + * The date indicating when the batch was cancelled. + * + * @var \Carbon\CarbonImmutable|null + */ + public $cancelledAt; + + /** + * The date indicating when the batch was finished. + * + * @var \Carbon\CarbonImmutable|null + */ + public $finishedAt; + + /** + * Create a new batch instance. + * + * @param \Illuminate\Contracts\Queue\Factory $queue + * @param \Illuminate\Bus\BatchRepository $repository + * @param string $id + * @param string $name + * @param int $totalJobs + * @param int $pendingJobs + * @param int $failedJobs + * @param array $failedJobIds + * @param array $options + * @param \Carbon\CarbonImmutable $createdAt + * @param \Carbon\CarbonImmutable|null $cancelledAt + * @param \Carbon\CarbonImmutable|null $finishedAt + * @return void + */ + public function __construct(QueueFactory $queue, + BatchRepository $repository, + string $id, + string $name, + int $totalJobs, + int $pendingJobs, + int $failedJobs, + array $failedJobIds, + array $options, + CarbonImmutable $createdAt, + ?CarbonImmutable $cancelledAt = null, + ?CarbonImmutable $finishedAt = null) + { + $this->queue = $queue; + $this->repository = $repository; + $this->id = $id; + $this->name = $name; + $this->totalJobs = $totalJobs; + $this->pendingJobs = $pendingJobs; + $this->failedJobs = $failedJobs; + $this->failedJobIds = $failedJobIds; + $this->options = $options; + $this->createdAt = $createdAt; + $this->cancelledAt = $cancelledAt; + $this->finishedAt = $finishedAt; + } + + /** + * Get a fresh instance of the batch represented by this ID. + * + * @return self + */ + public function fresh() + { + return $this->repository->find($this->id); + } + + /** + * Add additional jobs to the batch. + * + * @param \Illuminate\Support\Enumerable|array $jobs + * @return self + */ + public function add($jobs) + { + $count = 0; + + $jobs = Collection::wrap($jobs)->map(function ($job) use (&$count) { + $job = $job instanceof Closure ? CallQueuedClosure::create($job) : $job; + + if (is_array($job)) { + $count += count($job); + + return with($this->prepareBatchedChain($job), function ($chain) { + return $chain->first() + ->allOnQueue($this->options['queue'] ?? null) + ->allOnConnection($this->options['connection'] ?? null) + ->chain($chain->slice(1)->values()->all()); + }); + } else { + $job->withBatchId($this->id); + + $count++; + } + + return $job; + }); + + $this->repository->transaction(function () use ($jobs, $count) { + $this->repository->incrementTotalJobs($this->id, $count); + + $this->queue->connection($this->options['connection'] ?? null)->bulk( + $jobs->all(), + $data = '', + $this->options['queue'] ?? null + ); + }); + + return $this->fresh(); + } + + /** + * Prepare a chain that exists within the jobs being added. + * + * @param array $chain + * @return \Illuminate\Support\Collection + */ + protected function prepareBatchedChain(array $chain) + { + return collect($chain)->map(function ($job) { + $job = $job instanceof Closure ? CallQueuedClosure::create($job) : $job; + + return $job->withBatchId($this->id); + }); + } + + /** + * Get the total number of jobs that have been processed by the batch thus far. + * + * @return int + */ + public function processedJobs() + { + return $this->totalJobs - $this->pendingJobs; + } + + /** + * Get the percentage of jobs that have been processed (between 0-100). + * + * @return int + */ + public function progress() + { + return $this->totalJobs > 0 ? round(($this->processedJobs() / $this->totalJobs) * 100) : 0; + } + + /** + * Record that a job within the batch finished successfully, executing any callbacks if necessary. + * + * @param string $jobId + * @return void + */ + public function recordSuccessfulJob(string $jobId) + { + $counts = $this->decrementPendingJobs($jobId); + + if ($counts->pendingJobs === 0) { + $this->repository->markAsFinished($this->id); + } + + if ($counts->pendingJobs === 0 && $this->hasThenCallbacks()) { + $batch = $this->fresh(); + + collect($this->options['then'])->each(function ($handler) use ($batch) { + $this->invokeHandlerCallback($handler, $batch); + }); + } + + if ($counts->allJobsHaveRanExactlyOnce() && $this->hasFinallyCallbacks()) { + $batch = $this->fresh(); + + collect($this->options['finally'])->each(function ($handler) use ($batch) { + $this->invokeHandlerCallback($handler, $batch); + }); + } + } + + /** + * Decrement the pending jobs for the batch. + * + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function decrementPendingJobs(string $jobId) + { + return $this->repository->decrementPendingJobs($this->id, $jobId); + } + + /** + * Determine if the batch has finished executing. + * + * @return bool + */ + public function finished() + { + return ! is_null($this->finishedAt); + } + + /** + * Determine if the batch has "success" callbacks. + * + * @return bool + */ + public function hasThenCallbacks() + { + return isset($this->options['then']) && ! empty($this->options['then']); + } + + /** + * Determine if the batch allows jobs to fail without cancelling the batch. + * + * @return bool + */ + public function allowsFailures() + { + return Arr::get($this->options, 'allowFailures', false) === true; + } + + /** + * Determine if the batch has job failures. + * + * @return bool + */ + public function hasFailures() + { + return $this->failedJobs > 0; + } + + /** + * Record that a job within the batch failed to finish successfully, executing any callbacks if necessary. + * + * @param string $jobId + * @param \Throwable $e + * @return void + */ + public function recordFailedJob(string $jobId, $e) + { + $counts = $this->incrementFailedJobs($jobId); + + if ($counts->failedJobs === 1 && ! $this->allowsFailures()) { + $this->cancel(); + } + + if ($counts->failedJobs === 1 && $this->hasCatchCallbacks()) { + $batch = $this->fresh(); + + collect($this->options['catch'])->each(function ($handler) use ($batch, $e) { + $this->invokeHandlerCallback($handler, $batch, $e); + }); + } + + if ($counts->allJobsHaveRanExactlyOnce() && $this->hasFinallyCallbacks()) { + $batch = $this->fresh(); + + collect($this->options['finally'])->each(function ($handler) use ($batch, $e) { + $this->invokeHandlerCallback($handler, $batch, $e); + }); + } + } + + /** + * Increment the failed jobs for the batch. + * + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function incrementFailedJobs(string $jobId) + { + return $this->repository->incrementFailedJobs($this->id, $jobId); + } + + /** + * Determine if the batch has "catch" callbacks. + * + * @return bool + */ + public function hasCatchCallbacks() + { + return isset($this->options['catch']) && ! empty($this->options['catch']); + } + + /** + * Determine if the batch has "finally" callbacks. + * + * @return bool + */ + public function hasFinallyCallbacks() + { + return isset($this->options['finally']) && ! empty($this->options['finally']); + } + + /** + * Cancel the batch. + * + * @return void + */ + public function cancel() + { + $this->repository->cancel($this->id); + } + + /** + * Determine if the batch has been cancelled. + * + * @return bool + */ + public function canceled() + { + return $this->cancelled(); + } + + /** + * Determine if the batch has been cancelled. + * + * @return bool + */ + public function cancelled() + { + return ! is_null($this->cancelledAt); + } + + /** + * Delete the batch from storage. + * + * @return void + */ + public function delete() + { + $this->repository->delete($this->id); + } + + /** + * Invoke a batch callback handler. + * + * @param callable $handler + * @param \Illuminate\Bus\Batch $batch + * @param \Throwable|null $e + * @return void + */ + protected function invokeHandlerCallback($handler, Batch $batch, Throwable $e = null) + { + try { + return $handler($batch, $e); + } catch (Throwable $e) { + if (function_exists('report')) { + report($e); + } + } + } + + /** + * Convert the batch to an array. + * + * @return array + */ + public function toArray() + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'totalJobs' => $this->totalJobs, + 'pendingJobs' => $this->pendingJobs, + 'processedJobs' => $this->processedJobs(), + 'progress' => $this->progress(), + 'failedJobs' => $this->failedJobs, + 'options' => $this->options, + 'createdAt' => $this->createdAt, + 'cancelledAt' => $this->cancelledAt, + 'finishedAt' => $this->finishedAt, + ]; + } + + /** + * Get the JSON serializable representation of the object. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Dynamically access the batch's "options" via properties. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + return $this->options[$key] ?? null; + } +} diff --git a/src/Illuminate/Bus/BatchFactory.php b/src/Illuminate/Bus/BatchFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..2c3a4e96ce57f1847d086d9c17067cd5da8dfcdf --- /dev/null +++ b/src/Illuminate/Bus/BatchFactory.php @@ -0,0 +1,58 @@ +<?php + +namespace Illuminate\Bus; + +use Carbon\CarbonImmutable; +use Illuminate\Contracts\Queue\Factory as QueueFactory; + +class BatchFactory +{ + /** + * The queue factory implementation. + * + * @var \Illuminate\Contracts\Queue\Factory + */ + protected $queue; + + /** + * Create a new batch factory instance. + * + * @param \Illuminate\Contracts\Queue\Factory $queue + * @return void + */ + public function __construct(QueueFactory $queue) + { + $this->queue = $queue; + } + + /** + * Create a new batch instance. + * + * @param \Illuminate\Bus\BatchRepository $repository + * @param string $id + * @param string $name + * @param int $totalJobs + * @param int $pendingJobs + * @param int $failedJobs + * @param array $failedJobIds + * @param array $options + * @param \Carbon\CarbonImmutable $createdAt + * @param \Carbon\CarbonImmutable|null $cancelledAt + * @param \Carbon\CarbonImmutable|null $finishedAt + * @return \Illuminate\Bus\Batch + */ + public function make(BatchRepository $repository, + string $id, + string $name, + int $totalJobs, + int $pendingJobs, + int $failedJobs, + array $failedJobIds, + array $options, + CarbonImmutable $createdAt, + ?CarbonImmutable $cancelledAt, + ?CarbonImmutable $finishedAt) + { + return new Batch($this->queue, $repository, $id, $name, $totalJobs, $pendingJobs, $failedJobs, $failedJobIds, $options, $createdAt, $cancelledAt, $finishedAt); + } +} diff --git a/src/Illuminate/Bus/BatchRepository.php b/src/Illuminate/Bus/BatchRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..098ccef20ed67ca87a17e37273ca98435e834817 --- /dev/null +++ b/src/Illuminate/Bus/BatchRepository.php @@ -0,0 +1,92 @@ +<?php + +namespace Illuminate\Bus; + +use Closure; + +interface BatchRepository +{ + /** + * Retrieve a list of batches. + * + * @param int $limit + * @param mixed $before + * @return \Illuminate\Bus\Batch[] + */ + public function get($limit, $before); + + /** + * Retrieve information about an existing batch. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function find(string $batchId); + + /** + * Store a new pending batch. + * + * @param \Illuminate\Bus\PendingBatch $batch + * @return \Illuminate\Bus\Batch + */ + public function store(PendingBatch $batch); + + /** + * Increment the total number of jobs within the batch. + * + * @param string $batchId + * @param int $amount + * @return void + */ + public function incrementTotalJobs(string $batchId, int $amount); + + /** + * Decrement the total number of pending jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function decrementPendingJobs(string $batchId, string $jobId); + + /** + * Increment the total number of failed jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function incrementFailedJobs(string $batchId, string $jobId); + + /** + * Mark the batch that has the given ID as finished. + * + * @param string $batchId + * @return void + */ + public function markAsFinished(string $batchId); + + /** + * Cancel the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function cancel(string $batchId); + + /** + * Delete the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function delete(string $batchId); + + /** + * Execute the given Closure within a storage specific transaction. + * + * @param \Closure $callback + * @return mixed + */ + public function transaction(Closure $callback); +} diff --git a/src/Illuminate/Bus/Batchable.php b/src/Illuminate/Bus/Batchable.php new file mode 100644 index 0000000000000000000000000000000000000000..3d88c3c7633b063ec4c3adeaf3663cb74320a974 --- /dev/null +++ b/src/Illuminate/Bus/Batchable.php @@ -0,0 +1,52 @@ +<?php + +namespace Illuminate\Bus; + +use Illuminate\Container\Container; + +trait Batchable +{ + /** + * The batch ID (if applicable). + * + * @var string + */ + public $batchId; + + /** + * Get the batch instance for the job, if applicable. + * + * @return \Illuminate\Bus\Batch|null + */ + public function batch() + { + if ($this->batchId) { + return Container::getInstance()->make(BatchRepository::class)->find($this->batchId); + } + } + + /** + * Determine if the batch is still active and processing. + * + * @return bool + */ + public function batching() + { + $batch = $this->batch(); + + return $batch && ! $batch->cancelled(); + } + + /** + * Set the batch ID on the job. + * + * @param string $batchId + * @return $this + */ + public function withBatchId(string $batchId) + { + $this->batchId = $batchId; + + return $this; + } +} diff --git a/src/Illuminate/Bus/BusServiceProvider.php b/src/Illuminate/Bus/BusServiceProvider.php index 0b56aab56f68093662b1f104d39ec1878ef47c66..ff3eef81b6c5b779df12fb991de7d3d1ab173626 100644 --- a/src/Illuminate/Bus/BusServiceProvider.php +++ b/src/Illuminate/Bus/BusServiceProvider.php @@ -23,6 +23,8 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider }); }); + $this->registerBatchServices(); + $this->app->alias( Dispatcher::class, DispatcherContract::class ); @@ -32,6 +34,24 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider ); } + /** + * Register the batch handling services. + * + * @return void + */ + protected function registerBatchServices() + { + $this->app->singleton(BatchRepository::class, DatabaseBatchRepository::class); + + $this->app->singleton(DatabaseBatchRepository::class, function ($app) { + return new DatabaseBatchRepository( + $app->make(BatchFactory::class), + $app->make('db')->connection($app->config->get('queue.batching.database')), + $app->config->get('queue.batching.table', 'job_batches') + ); + }); + } + /** * Get the services provided by the provider. * @@ -43,6 +63,7 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider Dispatcher::class, DispatcherContract::class, QueueingDispatcherContract::class, + BatchRepository::class, ]; } } diff --git a/src/Illuminate/Bus/DatabaseBatchRepository.php b/src/Illuminate/Bus/DatabaseBatchRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..ee544b04223db9ddd2aa388fe676af3ea606f65f --- /dev/null +++ b/src/Illuminate/Bus/DatabaseBatchRepository.php @@ -0,0 +1,347 @@ +<?php + +namespace Illuminate\Bus; + +use Carbon\CarbonImmutable; +use Closure; +use DateTimeInterface; +use Illuminate\Database\Connection; +use Illuminate\Database\PostgresConnection; +use Illuminate\Database\Query\Expression; +use Illuminate\Support\Str; + +class DatabaseBatchRepository implements PrunableBatchRepository +{ + /** + * The batch factory instance. + * + * @var \Illuminate\Bus\BatchFactory + */ + protected $factory; + + /** + * The database connection instance. + * + * @var \Illuminate\Database\Connection + */ + protected $connection; + + /** + * The database table to use to store batch information. + * + * @var string + */ + protected $table; + + /** + * Create a new batch repository instance. + * + * @param \Illuminate\Bus\BatchFactory $factory + * @param \Illuminate\Database\Connection $connection + * @param string $table + */ + public function __construct(BatchFactory $factory, Connection $connection, string $table) + { + $this->factory = $factory; + $this->connection = $connection; + $this->table = $table; + } + + /** + * Retrieve a list of batches. + * + * @param int $limit + * @param mixed $before + * @return \Illuminate\Bus\Batch[] + */ + public function get($limit = 50, $before = null) + { + return $this->connection->table($this->table) + ->orderByDesc('id') + ->take($limit) + ->when($before, function ($q) use ($before) { + return $q->where('id', '<', $before); + }) + ->get() + ->map(function ($batch) { + return $this->toBatch($batch); + }) + ->all(); + } + + /** + * Retrieve information about an existing batch. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function find(string $batchId) + { + $batch = $this->connection->table($this->table) + ->where('id', $batchId) + ->first(); + + if ($batch) { + return $this->toBatch($batch); + } + } + + /** + * Store a new pending batch. + * + * @param \Illuminate\Bus\PendingBatch $batch + * @return \Illuminate\Bus\Batch + */ + public function store(PendingBatch $batch) + { + $id = (string) Str::orderedUuid(); + + $this->connection->table($this->table)->insert([ + 'id' => $id, + 'name' => $batch->name, + 'total_jobs' => 0, + 'pending_jobs' => 0, + 'failed_jobs' => 0, + 'failed_job_ids' => '[]', + 'options' => $this->serialize($batch->options), + 'created_at' => time(), + 'cancelled_at' => null, + 'finished_at' => null, + ]); + + return $this->find($id); + } + + /** + * Increment the total number of jobs within the batch. + * + * @param string $batchId + * @param int $amount + * @return void + */ + public function incrementTotalJobs(string $batchId, int $amount) + { + $this->connection->table($this->table)->where('id', $batchId)->update([ + 'total_jobs' => new Expression('total_jobs + '.$amount), + 'pending_jobs' => new Expression('pending_jobs + '.$amount), + 'finished_at' => null, + ]); + } + + /** + * Decrement the total number of pending jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function decrementPendingJobs(string $batchId, string $jobId) + { + $values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) { + return [ + 'pending_jobs' => $batch->pending_jobs - 1, + 'failed_jobs' => $batch->failed_jobs, + 'failed_job_ids' => json_encode(array_values(array_diff(json_decode($batch->failed_job_ids, true), [$jobId]))), + ]; + }); + + return new UpdatedBatchJobCounts( + $values['pending_jobs'], + $values['failed_jobs'] + ); + } + + /** + * Increment the total number of failed jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function incrementFailedJobs(string $batchId, string $jobId) + { + $values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) { + return [ + 'pending_jobs' => $batch->pending_jobs, + 'failed_jobs' => $batch->failed_jobs + 1, + 'failed_job_ids' => json_encode(array_values(array_unique(array_merge(json_decode($batch->failed_job_ids, true), [$jobId])))), + ]; + }); + + return new UpdatedBatchJobCounts( + $values['pending_jobs'], + $values['failed_jobs'] + ); + } + + /** + * Update an atomic value within the batch. + * + * @param string $batchId + * @param \Closure $callback + * @return int|null + */ + protected function updateAtomicValues(string $batchId, Closure $callback) + { + return $this->connection->transaction(function () use ($batchId, $callback) { + $batch = $this->connection->table($this->table)->where('id', $batchId) + ->lockForUpdate() + ->first(); + + return is_null($batch) ? [] : tap($callback($batch), function ($values) use ($batchId) { + $this->connection->table($this->table)->where('id', $batchId)->update($values); + }); + }); + } + + /** + * Mark the batch that has the given ID as finished. + * + * @param string $batchId + * @return void + */ + public function markAsFinished(string $batchId) + { + $this->connection->table($this->table)->where('id', $batchId)->update([ + 'finished_at' => time(), + ]); + } + + /** + * Cancel the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function cancel(string $batchId) + { + $this->connection->table($this->table)->where('id', $batchId)->update([ + 'cancelled_at' => time(), + 'finished_at' => time(), + ]); + } + + /** + * Delete the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function delete(string $batchId) + { + $this->connection->table($this->table)->where('id', $batchId)->delete(); + } + + /** + * Prune all of the entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function prune(DateTimeInterface $before) + { + $query = $this->connection->table($this->table) + ->whereNotNull('finished_at') + ->where('finished_at', '<', $before->getTimestamp()); + + $totalDeleted = 0; + + do { + $deleted = $query->take(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + + /** + * Prune all of the unfinished entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function pruneUnfinished(DateTimeInterface $before) + { + $query = $this->connection->table($this->table) + ->whereNull('finished_at') + ->where('created_at', '<', $before->getTimestamp()); + + $totalDeleted = 0; + + do { + $deleted = $query->take(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + + /** + * Execute the given Closure within a storage specific transaction. + * + * @param \Closure $callback + * @return mixed + */ + public function transaction(Closure $callback) + { + return $this->connection->transaction(function () use ($callback) { + return $callback(); + }); + } + + /** + * Serialize the given value. + * + * @param mixed $value + * @return string + */ + protected function serialize($value) + { + $serialized = serialize($value); + + return $this->connection instanceof PostgresConnection + ? base64_encode($serialized) + : $serialized; + } + + /** + * Unserialize the given value. + * + * @param string $serialized + * @return mixed + */ + protected function unserialize($serialized) + { + if ($this->connection instanceof PostgresConnection && + ! Str::contains($serialized, [':', ';'])) { + $serialized = base64_decode($serialized); + } + + return unserialize($serialized); + } + + /** + * Convert the given raw batch to a Batch object. + * + * @param object $batch + * @return \Illuminate\Bus\Batch + */ + protected function toBatch($batch) + { + return $this->factory->make( + $this, + $batch->id, + $batch->name, + (int) $batch->total_jobs, + (int) $batch->pending_jobs, + (int) $batch->failed_jobs, + json_decode($batch->failed_job_ids, true), + $this->unserialize($batch->options), + CarbonImmutable::createFromTimestamp($batch->created_at), + $batch->cancelled_at ? CarbonImmutable::createFromTimestamp($batch->cancelled_at) : $batch->cancelled_at, + $batch->finished_at ? CarbonImmutable::createFromTimestamp($batch->finished_at) : $batch->finished_at + ); + } +} diff --git a/src/Illuminate/Bus/Dispatcher.php b/src/Illuminate/Bus/Dispatcher.php index 9d8096209dca9273c7b0d60ed77d05cbcdb69935..4dc390e653fbc3055571e3d0296cd71ada5e6360 100644 --- a/src/Illuminate/Bus/Dispatcher.php +++ b/src/Illuminate/Bus/Dispatcher.php @@ -7,7 +7,11 @@ use Illuminate\Contracts\Bus\QueueingDispatcher; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Queue\Queue; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\PendingChain; use Illuminate\Pipeline\Pipeline; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Jobs\SyncJob; +use Illuminate\Support\Collection; use RuntimeException; class Dispatcher implements QueueingDispatcher @@ -69,15 +73,33 @@ class Dispatcher implements QueueingDispatcher */ public function dispatch($command) { - if ($this->queueResolver && $this->commandShouldBeQueued($command)) { - return $this->dispatchToQueue($command); + return $this->queueResolver && $this->commandShouldBeQueued($command) + ? $this->dispatchToQueue($command) + : $this->dispatchNow($command); + } + + /** + * Dispatch a command to its appropriate handler in the current process. + * + * Queueable jobs will be dispatched to the "sync" queue. + * + * @param mixed $command + * @param mixed $handler + * @return mixed + */ + public function dispatchSync($command, $handler = null) + { + if ($this->queueResolver && + $this->commandShouldBeQueued($command) && + method_exists($command, 'onConnection')) { + return $this->dispatchToQueue($command->onConnection('sync')); } - return $this->dispatchNow($command); + return $this->dispatchNow($command, $handler); } /** - * Dispatch a command to its appropriate handler in the current process. + * Dispatch a command to its appropriate handler in the current process without using the synchronous queue. * * @param mixed $command * @param mixed $handler @@ -85,19 +107,66 @@ class Dispatcher implements QueueingDispatcher */ public function dispatchNow($command, $handler = null) { + $uses = class_uses_recursive($command); + + if (in_array(InteractsWithQueue::class, $uses) && + in_array(Queueable::class, $uses) && + ! $command->job) { + $command->setJob(new SyncJob($this->container, json_encode([]), 'sync', 'sync')); + } + if ($handler || $handler = $this->getCommandHandler($command)) { $callback = function ($command) use ($handler) { - return $handler->handle($command); + $method = method_exists($handler, 'handle') ? 'handle' : '__invoke'; + + return $handler->{$method}($command); }; } else { $callback = function ($command) { - return $this->container->call([$command, 'handle']); + $method = method_exists($command, 'handle') ? 'handle' : '__invoke'; + + return $this->container->call([$command, $method]); }; } return $this->pipeline->send($command)->through($this->pipes)->then($callback); } + /** + * Attempt to find the batch with the given ID. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function findBatch(string $batchId) + { + return $this->container->make(BatchRepository::class)->find($batchId); + } + + /** + * Create a new batch of queueable jobs. + * + * @param \Illuminate\Support\Collection|array|mixed $jobs + * @return \Illuminate\Bus\PendingBatch + */ + public function batch($jobs) + { + return new PendingBatch($this->container, Collection::wrap($jobs)); + } + + /** + * Create a new chain of queueable jobs. + * + * @param \Illuminate\Support\Collection|array $jobs + * @return \Illuminate\Foundation\Bus\PendingChain + */ + public function chain($jobs) + { + $jobs = Collection::wrap($jobs); + + return new PendingChain($jobs->shift(), $jobs->toArray()); + } + /** * Determine if the given command has a handler. * @@ -140,6 +209,8 @@ class Dispatcher implements QueueingDispatcher * * @param mixed $command * @return mixed + * + * @throws \RuntimeException */ public function dispatchToQueue($command) { diff --git a/src/Illuminate/Bus/Events/BatchDispatched.php b/src/Illuminate/Bus/Events/BatchDispatched.php new file mode 100644 index 0000000000000000000000000000000000000000..b9a161adb48cad12a815b86b88339c48486e2d5d --- /dev/null +++ b/src/Illuminate/Bus/Events/BatchDispatched.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Bus\Events; + +use Illuminate\Bus\Batch; + +class BatchDispatched +{ + /** + * The batch instance. + * + * @var \Illuminate\Bus\Batch + */ + public $batch; + + /** + * Create a new event instance. + * + * @param \Illuminate\Bus\Batch $batch + * @return void + */ + public function __construct(Batch $batch) + { + $this->batch = $batch; + } +} diff --git a/src/Illuminate/Bus/PendingBatch.php b/src/Illuminate/Bus/PendingBatch.php new file mode 100644 index 0000000000000000000000000000000000000000..77cb7fa88bfcd54eb99f3af0415ad09a2c10929d --- /dev/null +++ b/src/Illuminate/Bus/PendingBatch.php @@ -0,0 +1,272 @@ +<?php + +namespace Illuminate\Bus; + +use Closure; +use Illuminate\Bus\Events\BatchDispatched; +use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Events\Dispatcher as EventDispatcher; +use Illuminate\Queue\SerializableClosureFactory; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Throwable; + +class PendingBatch +{ + /** + * The IoC container instance. + * + * @var \Illuminate\Contracts\Container\Container + */ + protected $container; + + /** + * The batch name. + * + * @var string + */ + public $name = ''; + + /** + * The jobs that belong to the batch. + * + * @var \Illuminate\Support\Collection + */ + public $jobs; + + /** + * The batch options. + * + * @var array + */ + public $options = []; + + /** + * Create a new pending batch instance. + * + * @param \Illuminate\Contracts\Container\Container $container + * @param \Illuminate\Support\Collection $jobs + * @return void + */ + public function __construct(Container $container, Collection $jobs) + { + $this->container = $container; + $this->jobs = $jobs; + } + + /** + * Add jobs to the batch. + * + * @param iterable $jobs + * @return $this + */ + public function add($jobs) + { + foreach ($jobs as $job) { + $this->jobs->push($job); + } + + return $this; + } + + /** + * Add a callback to be executed after all jobs in the batch have executed successfully. + * + * @param callable $callback + * @return $this + */ + public function then($callback) + { + $this->options['then'][] = $callback instanceof Closure + ? SerializableClosureFactory::make($callback) + : $callback; + + return $this; + } + + /** + * Get the "then" callbacks that have been registered with the pending batch. + * + * @return array + */ + public function thenCallbacks() + { + return $this->options['then'] ?? []; + } + + /** + * Add a callback to be executed after the first failing job in the batch. + * + * @param callable $callback + * @return $this + */ + public function catch($callback) + { + $this->options['catch'][] = $callback instanceof Closure + ? SerializableClosureFactory::make($callback) + : $callback; + + return $this; + } + + /** + * Get the "catch" callbacks that have been registered with the pending batch. + * + * @return array + */ + public function catchCallbacks() + { + return $this->options['catch'] ?? []; + } + + /** + * Add a callback to be executed after the batch has finished executing. + * + * @param callable $callback + * @return $this + */ + public function finally($callback) + { + $this->options['finally'][] = $callback instanceof Closure + ? SerializableClosureFactory::make($callback) + : $callback; + + return $this; + } + + /** + * Get the "finally" callbacks that have been registered with the pending batch. + * + * @return array + */ + public function finallyCallbacks() + { + return $this->options['finally'] ?? []; + } + + /** + * Indicate that the batch should not be cancelled when a job within the batch fails. + * + * @param bool $allowFailures + * @return $this + */ + public function allowFailures($allowFailures = true) + { + $this->options['allowFailures'] = $allowFailures; + + return $this; + } + + /** + * Determine if the pending batch allows jobs to fail without cancelling the batch. + * + * @return bool + */ + public function allowsFailures() + { + return Arr::get($this->options, 'allowFailures', false) === true; + } + + /** + * Set the name for the batch. + * + * @param string $name + * @return $this + */ + public function name(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Specify the queue connection that the batched jobs should run on. + * + * @param string $connection + * @return $this + */ + public function onConnection(string $connection) + { + $this->options['connection'] = $connection; + + return $this; + } + + /** + * Get the connection used by the pending batch. + * + * @return string|null + */ + public function connection() + { + return $this->options['connection'] ?? null; + } + + /** + * Specify the queue that the batched jobs should run on. + * + * @param string $queue + * @return $this + */ + public function onQueue(string $queue) + { + $this->options['queue'] = $queue; + + return $this; + } + + /** + * Get the queue used by the pending batch. + * + * @return string|null + */ + public function queue() + { + return $this->options['queue'] ?? null; + } + + /** + * Add additional data into the batch's options array. + * + * @param string $key + * @param mixed $value + * @return $this + */ + public function withOption(string $key, $value) + { + $this->options[$key] = $value; + + return $this; + } + + /** + * Dispatch the batch. + * + * @return \Illuminate\Bus\Batch + * + * @throws \Throwable + */ + public function dispatch() + { + $repository = $this->container->make(BatchRepository::class); + + try { + $batch = $repository->store($this); + + $batch = $batch->add($this->jobs); + } catch (Throwable $e) { + if (isset($batch)) { + $repository->delete($batch->id); + } + + throw $e; + } + + $this->container->make(EventDispatcher::class)->dispatch( + new BatchDispatched($batch) + ); + + return $batch; + } +} diff --git a/src/Illuminate/Bus/PrunableBatchRepository.php b/src/Illuminate/Bus/PrunableBatchRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..3f972553b5974010f33f0b889ec79b169c5874ed --- /dev/null +++ b/src/Illuminate/Bus/PrunableBatchRepository.php @@ -0,0 +1,16 @@ +<?php + +namespace Illuminate\Bus; + +use DateTimeInterface; + +interface PrunableBatchRepository extends BatchRepository +{ + /** + * Prune all of the entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function prune(DateTimeInterface $before); +} diff --git a/src/Illuminate/Bus/Queueable.php b/src/Illuminate/Bus/Queueable.php index 073347cc5255f2ae903f5663df2128a8dd551df5..8e9306059c1093fd511b809a0666a0d6f82c67ab 100644 --- a/src/Illuminate/Bus/Queueable.php +++ b/src/Illuminate/Bus/Queueable.php @@ -2,7 +2,10 @@ namespace Illuminate\Bus; +use Closure; +use Illuminate\Queue\CallQueuedClosure; use Illuminate\Support\Arr; +use RuntimeException; trait Queueable { @@ -34,6 +37,13 @@ trait Queueable */ public $chainQueue; + /** + * The callbacks to be executed on chain failure. + * + * @var array|null + */ + public $chainCatchCallbacks; + /** * The number of seconds before the job should be made available. * @@ -41,8 +51,17 @@ trait Queueable */ public $delay; + /** + * Indicates whether the job should be dispatched after all database transactions have committed. + * + * @var bool|null + */ + public $afterCommit; + /** * The middleware the job should be dispatched through. + * + * @var array */ public $middleware = []; @@ -121,13 +140,27 @@ trait Queueable } /** - * Get the middleware the job should be dispatched through. + * Indicate that the job should be dispatched after all database transactions have committed. + * + * @return $this + */ + public function afterCommit() + { + $this->afterCommit = true; + + return $this; + } + + /** + * Indicate that the job should not wait until database transactions have been committed before dispatching. * - * @return array + * @return $this */ - public function middleware() + public function beforeCommit() { - return []; + $this->afterCommit = false; + + return $this; } /** @@ -152,12 +185,35 @@ trait Queueable public function chain($chain) { $this->chained = collect($chain)->map(function ($job) { - return serialize($job); + return $this->serializeJob($job); })->all(); return $this; } + /** + * Serialize a job for queuing. + * + * @param mixed $job + * @return string + * + * @throws \RuntimeException + */ + protected function serializeJob($job) + { + if ($job instanceof Closure) { + if (! class_exists(CallQueuedClosure::class)) { + throw new RuntimeException( + 'To enable support for closure jobs, please install the illuminate/queue package.' + ); + } + + $job = CallQueuedClosure::create($job); + } + + return serialize($job); + } + /** * Dispatch the next job on the chain. * @@ -174,7 +230,21 @@ trait Queueable $next->chainConnection = $this->chainConnection; $next->chainQueue = $this->chainQueue; + $next->chainCatchCallbacks = $this->chainCatchCallbacks; })); } } + + /** + * Invoke all of the chain's failed job callbacks. + * + * @param \Throwable $e + * @return void + */ + public function invokeChainCatchCallbacks($e) + { + collect($this->chainCatchCallbacks)->each(function ($callback) use ($e) { + $callback($e); + }); + } } diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php new file mode 100644 index 0000000000000000000000000000000000000000..a937b1869651950b1d4be700232036a710e1ef02 --- /dev/null +++ b/src/Illuminate/Bus/UniqueLock.php @@ -0,0 +1,48 @@ +<?php + +namespace Illuminate\Bus; + +use Illuminate\Contracts\Cache\Repository as Cache; + +class UniqueLock +{ + /** + * The cache repository implementation. + * + * @var \Illuminate\Contracts\Cache\Repository + */ + protected $cache; + + /** + * Create a new unique lock manager instance. + * + * @param \Illuminate\Contracts\Cache\Repository $cache + * @return void + */ + public function __construct(Cache $cache) + { + $this->cache = $cache; + } + + /** + * Attempt to acquire a lock for the given job. + * + * @param mixed $job + * @return bool + */ + public function acquire($job) + { + $uniqueId = method_exists($job, 'uniqueId') + ? $job->uniqueId() + : ($job->uniqueId ?? ''); + + $cache = method_exists($job, 'uniqueVia') + ? $job->uniqueVia() + : $this->cache; + + return (bool) $cache->lock( + $key = 'laravel_unique_job:'.get_class($job).$uniqueId, + $job->uniqueFor ?? 0 + )->get(); + } +} diff --git a/src/Illuminate/Bus/UpdatedBatchJobCounts.php b/src/Illuminate/Bus/UpdatedBatchJobCounts.php new file mode 100644 index 0000000000000000000000000000000000000000..83d33a44f2f7f801efcda582b5f7b617d1204ea8 --- /dev/null +++ b/src/Illuminate/Bus/UpdatedBatchJobCounts.php @@ -0,0 +1,43 @@ +<?php + +namespace Illuminate\Bus; + +class UpdatedBatchJobCounts +{ + /** + * The number of pending jobs remaining for the batch. + * + * @var int + */ + public $pendingJobs; + + /** + * The number of failed jobs that belong to the batch. + * + * @var int + */ + public $failedJobs; + + /** + * Create a new batch job counts object. + * + * @param int $pendingJobs + * @param int $failedJobs + * @return void + */ + public function __construct(int $pendingJobs = 0, int $failedJobs = 0) + { + $this->pendingJobs = $pendingJobs; + $this->failedJobs = $failedJobs; + } + + /** + * Determine if all jobs have run exactly once. + * + * @return bool + */ + public function allJobsHaveRanExactlyOnce() + { + return ($this->pendingJobs - $this->failedJobs) === 0; + } +} diff --git a/src/Illuminate/Bus/composer.json b/src/Illuminate/Bus/composer.json index e133ae3304c1439194194204e2206b1ab469f41f..12713a61c3bd891b862c7caee034a0c4477efb52 100644 --- a/src/Illuminate/Bus/composer.json +++ b/src/Illuminate/Bus/composer.json @@ -14,10 +14,11 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/pipeline": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/pipeline": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -26,9 +27,12 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, + "suggest": { + "illuminate/queue": "Required to use closures when chaining jobs (^7.0)." + }, "config": { "sort-packages": true }, diff --git a/src/Illuminate/Cache/ArrayLock.php b/src/Illuminate/Cache/ArrayLock.php index 8bb4938c4cae57d5fad6cb215bb88c28b277a67f..4c20783b236202bb5c9055f37ce64f5399bbe372 100644 --- a/src/Illuminate/Cache/ArrayLock.php +++ b/src/Illuminate/Cache/ArrayLock.php @@ -2,7 +2,7 @@ namespace Illuminate\Cache; -use Carbon\Carbon; +use Illuminate\Support\Carbon; class ArrayLock extends Lock { diff --git a/src/Illuminate/Cache/ArrayStore.php b/src/Illuminate/Cache/ArrayStore.php index 1914a83201c68287460ec84a937c94edefc86ee1..22b42ba5f10f5911a5e7b4301d3fcfc7f02f4646 100644 --- a/src/Illuminate/Cache/ArrayStore.php +++ b/src/Illuminate/Cache/ArrayStore.php @@ -23,6 +23,24 @@ class ArrayStore extends TaggableStore implements LockProvider */ public $locks = []; + /** + * Indicates if values are serialized within the store. + * + * @var bool + */ + protected $serializesValues; + + /** + * Create a new Array store. + * + * @param bool $serializesValues + * @return void + */ + public function __construct($serializesValues = false) + { + $this->serializesValues = $serializesValues; + } + /** * Retrieve an item from the cache by key. * @@ -45,7 +63,7 @@ class ArrayStore extends TaggableStore implements LockProvider return; } - return $item['value']; + return $this->serializesValues ? unserialize($item['value']) : $item['value']; } /** @@ -59,7 +77,7 @@ class ArrayStore extends TaggableStore implements LockProvider public function put($key, $value, $seconds) { $this->storage[$key] = [ - 'value' => $value, + 'value' => $this->serializesValues ? serialize($value) : $value, 'expiresAt' => $this->calculateExpiration($seconds), ]; @@ -75,15 +93,17 @@ class ArrayStore extends TaggableStore implements LockProvider */ public function increment($key, $value = 1) { - if (! isset($this->storage[$key])) { - $this->forever($key, $value); + if (! is_null($existing = $this->get($key))) { + return tap(((int) $existing) + $value, function ($incremented) use ($key) { + $value = $this->serializesValues ? serialize($incremented) : $incremented; - return $this->storage[$key]['value']; + $this->storage[$key]['value'] = $value; + }); } - $this->storage[$key]['value'] = ((int) $this->storage[$key]['value']) + $value; + $this->forever($key, $value); - return $this->storage[$key]['value']; + return $value; } /** diff --git a/src/Illuminate/Cache/CacheLock.php b/src/Illuminate/Cache/CacheLock.php new file mode 100644 index 0000000000000000000000000000000000000000..310d9fb5d35cbb4df203d36ea86fc4e3eda274e2 --- /dev/null +++ b/src/Illuminate/Cache/CacheLock.php @@ -0,0 +1,85 @@ +<?php + +namespace Illuminate\Cache; + +class CacheLock extends Lock +{ + /** + * The cache store implementation. + * + * @var \Illuminate\Contracts\Cache\Store + */ + protected $store; + + /** + * Create a new lock instance. + * + * @param \Illuminate\Contracts\Cache\Store $store + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return void + */ + public function __construct($store, $name, $seconds, $owner = null) + { + parent::__construct($name, $seconds, $owner); + + $this->store = $store; + } + + /** + * Attempt to acquire the lock. + * + * @return bool + */ + public function acquire() + { + if (method_exists($this->store, 'add') && $this->seconds > 0) { + return $this->store->add( + $this->name, $this->owner, $this->seconds + ); + } + + if (! is_null($this->store->get($this->name))) { + return false; + } + + return ($this->seconds > 0) + ? $this->store->put($this->name, $this->owner, $this->seconds) + : $this->store->forever($this->name, $this->owner, $this->seconds); + } + + /** + * Release the lock. + * + * @return bool + */ + public function release() + { + if ($this->isOwnedByCurrentProcess()) { + return $this->store->forget($this->name); + } + + return false; + } + + /** + * Releases this lock regardless of ownership. + * + * @return void + */ + public function forceRelease() + { + $this->store->forget($this->name); + } + + /** + * Returns the owner value written into the driver for this lock. + * + * @return mixed + */ + protected function getCurrentOwner() + { + return $this->store->get($this->name); + } +} diff --git a/src/Illuminate/Cache/CacheManager.php b/src/Illuminate/Cache/CacheManager.php index 33d1027bce1a0be49d0b73a7abc232cd94b72e6c..145b3e61727509717a4161d1a624c42f5f4b150f 100755 --- a/src/Illuminate/Cache/CacheManager.php +++ b/src/Illuminate/Cache/CacheManager.php @@ -138,11 +138,12 @@ class CacheManager implements FactoryContract /** * Create an instance of the array cache driver. * + * @param array $config * @return \Illuminate\Cache\Repository */ - protected function createArrayDriver() + protected function createArrayDriver(array $config) { - return $this->repository(new ArrayStore); + return $this->repository(new ArrayStore($config['serialize'] ?? false)); } /** @@ -198,7 +199,11 @@ class CacheManager implements FactoryContract $connection = $config['connection'] ?? 'default'; - return $this->repository(new RedisStore($redis, $this->getPrefix($config), $connection)); + $store = new RedisStore($redis, $this->getPrefix($config), $connection); + + return $this->repository( + $store->setLockConnection($config['lock_connection'] ?? $connection) + ); } /** @@ -211,11 +216,17 @@ class CacheManager implements FactoryContract { $connection = $this->app['db']->connection($config['connection'] ?? null); - return $this->repository( - new DatabaseStore( - $connection, $config['table'], $this->getPrefix($config) - ) + $store = new DatabaseStore( + $connection, + $config['table'], + $this->getPrefix($config), + $config['lock_table'] ?? 'cache_locks', + $config['lock_lottery'] ?? [2, 100] ); + + return $this->repository($store->setLockConnection( + $this->app['db']->connection($config['lock_connection'] ?? $config['connection'] ?? null) + )); } /** @@ -225,6 +236,27 @@ class CacheManager implements FactoryContract * @return \Illuminate\Cache\Repository */ protected function createDynamodbDriver(array $config) + { + $client = $this->newDynamodbClient($config); + + return $this->repository( + new DynamoDbStore( + $client, + $config['table'], + $config['attributes']['key'] ?? 'key', + $config['attributes']['value'] ?? 'value', + $config['attributes']['expiration'] ?? 'expires_at', + $this->getPrefix($config) + ) + ); + } + + /** + * Create new DynamoDb Client instance. + * + * @return DynamoDbClient + */ + protected function newDynamodbClient(array $config) { $dynamoConfig = [ 'region' => $config['region'], @@ -232,22 +264,13 @@ class CacheManager implements FactoryContract 'endpoint' => $config['endpoint'] ?? null, ]; - if ($config['key'] && $config['secret']) { + if (isset($config['key']) && isset($config['secret'])) { $dynamoConfig['credentials'] = Arr::only( $config, ['key', 'secret', 'token'] ); } - return $this->repository( - new DynamoDbStore( - new DynamoDbClient($dynamoConfig), - $config['table'], - $config['attributes']['key'] ?? 'key', - $config['attributes']['value'] ?? 'value', - $config['attributes']['expiration'] ?? 'expires_at', - $this->getPrefix($config) - ) - ); + return new DynamoDbClient($dynamoConfig); } /** @@ -309,7 +332,11 @@ class CacheManager implements FactoryContract */ protected function getConfig($name) { - return $this->app['config']["cache.stores.{$name}"]; + if (! is_null($name) && $name !== 'null') { + return $this->app['config']["cache.stores.{$name}"]; + } + + return ['driver' => 'null']; } /** @@ -352,6 +379,19 @@ class CacheManager implements FactoryContract return $this; } + /** + * Disconnect the given driver and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?? $this->getDefaultDriver(); + + unset($this->stores[$name]); + } + /** * Register a custom driver creator Closure. * diff --git a/src/Illuminate/Cache/CacheServiceProvider.php b/src/Illuminate/Cache/CacheServiceProvider.php index 46fa0ae2615c3e1dc6531e3c705637fe43c69c1b..662d556a5af748b51b1913cd16f2d5d00a703098 100755 --- a/src/Illuminate/Cache/CacheServiceProvider.php +++ b/src/Illuminate/Cache/CacheServiceProvider.php @@ -30,6 +30,12 @@ class CacheServiceProvider extends ServiceProvider implements DeferrableProvider $this->app->singleton('memcached.connector', function () { return new MemcachedConnector; }); + + $this->app->singleton(RateLimiter::class, function ($app) { + return new RateLimiter($app->make('cache')->driver( + $app['config']->get('cache.limiter') + )); + }); } /** @@ -40,7 +46,7 @@ class CacheServiceProvider extends ServiceProvider implements DeferrableProvider public function provides() { return [ - 'cache', 'cache.store', 'cache.psr6', 'memcached.connector', + 'cache', 'cache.store', 'cache.psr6', 'memcached.connector', RateLimiter::class, ]; } } diff --git a/src/Illuminate/Cache/Console/ClearCommand.php b/src/Illuminate/Cache/Console/ClearCommand.php index aa88964d729fccd478b9a4346aeeaaedef5e8511..8a37b8b29c7ecc64fb07b659c0ae21324e1763a6 100755 --- a/src/Illuminate/Cache/Console/ClearCommand.php +++ b/src/Illuminate/Cache/Console/ClearCommand.php @@ -116,7 +116,7 @@ class ClearCommand extends Command */ protected function tags() { - return array_filter(explode(',', $this->option('tags'))); + return array_filter(explode(',', $this->option('tags') ?? '')); } /** diff --git a/src/Illuminate/Cache/Console/stubs/cache.stub b/src/Illuminate/Cache/Console/stubs/cache.stub index 7b73e5fd1a695e7a8818ab42b4bb4ee96a63e670..88cd44590c029964801da3f058d89413f728ceb1 100644 --- a/src/Illuminate/Cache/Console/stubs/cache.stub +++ b/src/Illuminate/Cache/Console/stubs/cache.stub @@ -14,10 +14,16 @@ class CreateCacheTable extends Migration public function up() { Schema::create('cache', function (Blueprint $table) { - $table->string('key')->unique(); + $table->string('key')->primary(); $table->mediumText('value'); $table->integer('expiration'); }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); } /** @@ -28,5 +34,6 @@ class CreateCacheTable extends Migration public function down() { Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); } } diff --git a/src/Illuminate/Cache/DatabaseLock.php b/src/Illuminate/Cache/DatabaseLock.php new file mode 100644 index 0000000000000000000000000000000000000000..7fd05c19134aadd287ff600ebbd2951981b67d8e --- /dev/null +++ b/src/Illuminate/Cache/DatabaseLock.php @@ -0,0 +1,149 @@ +<?php + +namespace Illuminate\Cache; + +use Illuminate\Database\Connection; +use Illuminate\Database\QueryException; +use Illuminate\Support\Carbon; + +class DatabaseLock extends Lock +{ + /** + * The database connection instance. + * + * @var \Illuminate\Database\Connection + */ + protected $connection; + + /** + * The database table name. + * + * @var string + */ + protected $table; + + /** + * The prune probability odds. + * + * @var array + */ + protected $lottery; + + /** + * Create a new lock instance. + * + * @param \Illuminate\Database\Connection $connection + * @param string $table + * @param string $name + * @param int $seconds + * @param string|null $owner + * @param array $lottery + * @return void + */ + public function __construct(Connection $connection, $table, $name, $seconds, $owner = null, $lottery = [2, 100]) + { + parent::__construct($name, $seconds, $owner); + + $this->connection = $connection; + $this->table = $table; + $this->lottery = $lottery; + } + + /** + * Attempt to acquire the lock. + * + * @return bool + */ + public function acquire() + { + $acquired = false; + + try { + $this->connection->table($this->table)->insert([ + 'key' => $this->name, + 'owner' => $this->owner, + 'expiration' => $this->expiresAt(), + ]); + + $acquired = true; + } catch (QueryException $e) { + $updated = $this->connection->table($this->table) + ->where('key', $this->name) + ->where(function ($query) { + return $query->where('owner', $this->owner)->orWhere('expiration', '<=', time()); + })->update([ + 'owner' => $this->owner, + 'expiration' => $this->expiresAt(), + ]); + + $acquired = $updated >= 1; + } + + if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) { + $this->connection->table($this->table)->where('expiration', '<=', time())->delete(); + } + + return $acquired; + } + + /** + * Get the UNIX timestamp indicating when the lock should expire. + * + * @return int + */ + protected function expiresAt() + { + return $this->seconds > 0 ? time() + $this->seconds : Carbon::now()->addDays(1)->getTimestamp(); + } + + /** + * Release the lock. + * + * @return bool + */ + public function release() + { + if ($this->isOwnedByCurrentProcess()) { + $this->connection->table($this->table) + ->where('key', $this->name) + ->where('owner', $this->owner) + ->delete(); + + return true; + } + + return false; + } + + /** + * Releases this lock in disregard of ownership. + * + * @return void + */ + public function forceRelease() + { + $this->connection->table($this->table) + ->where('key', $this->name) + ->delete(); + } + + /** + * Returns the owner value written into the driver for this lock. + * + * @return string + */ + protected function getCurrentOwner() + { + return optional($this->connection->table($this->table)->where('key', $this->name)->first())->owner; + } + + /** + * Get the name of the database connection being used to manage the lock. + * + * @return string + */ + public function getConnectionName() + { + return $this->connection->getName(); + } +} diff --git a/src/Illuminate/Cache/DatabaseStore.php b/src/Illuminate/Cache/DatabaseStore.php index 48844ebd740b98f8348df94f930381fc7f0d712b..32d7a9fc06763a57af89f7dd915abd70e7ad64d9 100755 --- a/src/Illuminate/Cache/DatabaseStore.php +++ b/src/Illuminate/Cache/DatabaseStore.php @@ -4,13 +4,15 @@ namespace Illuminate\Cache; use Closure; use Exception; +use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Cache\Store; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\PostgresConnection; +use Illuminate\Database\QueryException; use Illuminate\Support\InteractsWithTime; use Illuminate\Support\Str; -class DatabaseStore implements Store +class DatabaseStore implements LockProvider, Store { use InteractsWithTime, RetrievesMultipleKeys; @@ -21,6 +23,13 @@ class DatabaseStore implements Store */ protected $connection; + /** + * The database connection instance that should be used to manage locks. + * + * @var \Illuminate\Database\ConnectionInterface + */ + protected $lockConnection; + /** * The name of the cache table. * @@ -35,19 +44,41 @@ class DatabaseStore implements Store */ protected $prefix; + /** + * The name of the cache locks table. + * + * @var string + */ + protected $lockTable; + + /** + * An array representation of the lock lottery odds. + * + * @var array + */ + protected $lockLottery; + /** * Create a new database store. * * @param \Illuminate\Database\ConnectionInterface $connection * @param string $table * @param string $prefix + * @param string $lockTable + * @param array $lockLottery * @return void */ - public function __construct(ConnectionInterface $connection, $table, $prefix = '') + public function __construct(ConnectionInterface $connection, + $table, + $prefix = '', + $lockTable = 'cache_locks', + $lockLottery = [2, 100]) { $this->table = $table; $this->prefix = $prefix; $this->connection = $connection; + $this->lockTable = $lockTable; + $this->lockLottery = $lockLottery; } /** @@ -94,9 +125,7 @@ class DatabaseStore implements Store public function put($key, $value, $seconds) { $key = $this->prefix.$key; - $value = $this->serialize($value); - $expiration = $this->getTime() + $seconds; try { @@ -108,6 +137,33 @@ class DatabaseStore implements Store } } + /** + * Store an item in the cache if the key doesn't exist. + * + * @param string $key + * @param mixed $value + * @param int $seconds + * @return bool + */ + public function add($key, $value, $seconds) + { + $key = $this->prefix.$key; + $value = $this->serialize($value); + $expiration = $this->getTime() + $seconds; + + try { + return $this->table()->insert(compact('key', 'value', 'expiration')); + } catch (QueryException $e) { + return $this->table() + ->where('key', $key) + ->where('expiration', '<=', $this->getTime()) + ->update([ + 'value' => $value, + 'expiration' => $expiration, + ]) >= 1; + } + } + /** * Increment the value of an item in the cache. * @@ -205,6 +261,38 @@ class DatabaseStore implements Store return $this->put($key, $value, 315360000); } + /** + * Get a lock instance. + * + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function lock($name, $seconds = 0, $owner = null) + { + return new DatabaseLock( + $this->lockConnection ?? $this->connection, + $this->lockTable, + $this->prefix.$name, + $seconds, + $owner, + $this->lockLottery + ); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @param string $name + * @param string $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function restoreLock($name, $owner) + { + return $this->lock($name, 0, $owner); + } + /** * Remove an item from the cache. * @@ -250,6 +338,19 @@ class DatabaseStore implements Store return $this->connection; } + /** + * Specify the name of the connection that should be used to manage locks. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @return $this + */ + public function setLockConnection($connection) + { + $this->lockConnection = $connection; + + return $this; + } + /** * Get the cache key prefix. * diff --git a/src/Illuminate/Cache/DynamoDbLock.php b/src/Illuminate/Cache/DynamoDbLock.php index 54eec53f78b5be3d183cc016fe31c2a5c1745670..92226079293892ac4247efec5150bee9a9ad91c7 100644 --- a/src/Illuminate/Cache/DynamoDbLock.php +++ b/src/Illuminate/Cache/DynamoDbLock.php @@ -34,9 +34,11 @@ class DynamoDbLock extends Lock */ public function acquire() { - return $this->dynamo->add( - $this->name, $this->owner, $this->seconds - ); + if ($this->seconds > 0) { + return $this->dynamo->add($this->name, $this->owner, $this->seconds); + } else { + return $this->dynamo->add($this->name, $this->owner, 86400); + } } /** diff --git a/src/Illuminate/Cache/DynamoDbStore.php b/src/Illuminate/Cache/DynamoDbStore.php index 4e663db4108a77f409ca2a2b82a331c9a52af34c..aa28a789fa36d928a4d19778d3ca64aa322c2882 100644 --- a/src/Illuminate/Cache/DynamoDbStore.php +++ b/src/Illuminate/Cache/DynamoDbStore.php @@ -525,4 +525,14 @@ class DynamoDbStore implements LockProvider, Store { $this->prefix = ! empty($prefix) ? $prefix.':' : ''; } + + /** + * Get the DynamoDb Client instance. + * + * @return DynamoDbClient + */ + public function getClient() + { + return $this->dynamo; + } } diff --git a/src/Illuminate/Cache/FileStore.php b/src/Illuminate/Cache/FileStore.php index 7295d9e6d2056b64fc87ca5b8bb8b7441875ad2a..42292295f0ce9ce0f51544f1e6f92619115f4a0f 100755 --- a/src/Illuminate/Cache/FileStore.php +++ b/src/Illuminate/Cache/FileStore.php @@ -3,13 +3,16 @@ namespace Illuminate\Cache; use Exception; +use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Cache\Store; +use Illuminate\Contracts\Filesystem\LockTimeoutException; use Illuminate\Filesystem\Filesystem; +use Illuminate\Filesystem\LockableFile; use Illuminate\Support\InteractsWithTime; -class FileStore implements Store +class FileStore implements Store, LockProvider { - use InteractsWithTime, RetrievesMultipleKeys; + use InteractsWithTime, HasCacheLock, RetrievesMultipleKeys; /** * The Illuminate Filesystem instance. @@ -75,7 +78,7 @@ class FileStore implements Store ); if ($result !== false && $result > 0) { - $this->ensureFileHasCorrectPermissions($path); + $this->ensurePermissionsAreCorrect($path); return true; } @@ -83,6 +86,45 @@ class FileStore implements Store return false; } + /** + * Store an item in the cache if the key doesn't exist. + * + * @param string $key + * @param mixed $value + * @param int $seconds + * @return bool + */ + public function add($key, $value, $seconds) + { + $this->ensureCacheDirectoryExists($path = $this->path($key)); + + $file = new LockableFile($path, 'c+'); + + try { + $file->getExclusiveLock(); + } catch (LockTimeoutException $e) { + $file->close(); + + return false; + } + + $expire = $file->read(10); + + if (empty($expire) || $this->currentTime() >= $expire) { + $file->truncate() + ->write($this->expiration($seconds).serialize($value)) + ->close(); + + $this->ensurePermissionsAreCorrect($path); + + return true; + } + + $file->close(); + + return false; + } + /** * Create the file cache directory if necessary. * @@ -91,18 +133,24 @@ class FileStore implements Store */ protected function ensureCacheDirectoryExists($path) { - if (! $this->files->exists(dirname($path))) { - $this->files->makeDirectory(dirname($path), 0777, true, true); + $directory = dirname($path); + + if (! $this->files->exists($directory)) { + $this->files->makeDirectory($directory, 0777, true, true); + + // We're creating two levels of directories (e.g. 7e/24), so we check them both... + $this->ensurePermissionsAreCorrect($directory); + $this->ensurePermissionsAreCorrect(dirname($directory)); } } /** - * Ensure the cache file has the correct permissions. + * Ensure the created node has the correct permissions. * * @param string $path * @return void */ - protected function ensureFileHasCorrectPermissions($path) + protected function ensurePermissionsAreCorrect($path) { if (is_null($this->filePermission) || intval($this->files->chmod($path), 8) == $this->filePermission) { diff --git a/src/Illuminate/Cache/HasCacheLock.php b/src/Illuminate/Cache/HasCacheLock.php new file mode 100644 index 0000000000000000000000000000000000000000..82ad9c2b31f50f6c9e9275503f1653a084fd1751 --- /dev/null +++ b/src/Illuminate/Cache/HasCacheLock.php @@ -0,0 +1,31 @@ +<?php + +namespace Illuminate\Cache; + +trait HasCacheLock +{ + /** + * Get a lock instance. + * + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function lock($name, $seconds = 0, $owner = null) + { + return new CacheLock($this, $name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @param string $name + * @param string $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function restoreLock($name, $owner) + { + return $this->lock($name, 0, $owner); + } +} diff --git a/src/Illuminate/Cache/Lock.php b/src/Illuminate/Cache/Lock.php index 0200c5a4702f482bae3135a32e4358579c950c21..bed170507a9aab9f2d08e7749fe844e2ca0191fe 100644 --- a/src/Illuminate/Cache/Lock.php +++ b/src/Illuminate/Cache/Lock.php @@ -32,6 +32,13 @@ abstract class Lock implements LockContract */ protected $owner; + /** + * The number of milliseconds to wait before re-attempting to acquire a lock while blocking. + * + * @var int + */ + protected $sleepMilliseconds = 250; + /** * Create a new lock instance. * @@ -98,7 +105,7 @@ abstract class Lock implements LockContract * * @param int $seconds * @param callable|null $callback - * @return bool + * @return mixed * * @throws \Illuminate\Contracts\Cache\LockTimeoutException */ @@ -107,7 +114,7 @@ abstract class Lock implements LockContract $starting = $this->currentTime(); while (! $this->acquire()) { - usleep(250 * 1000); + usleep($this->sleepMilliseconds * 1000); if ($this->currentTime() - $seconds >= $starting) { throw new LockTimeoutException; @@ -144,4 +151,17 @@ abstract class Lock implements LockContract { return $this->getCurrentOwner() === $this->owner; } + + /** + * Specify the number of milliseconds to sleep in between blocked lock acquisition attempts. + * + * @param int $milliseconds + * @return $this + */ + public function betweenBlockedAttemptsSleepFor($milliseconds) + { + $this->sleepMilliseconds = $milliseconds; + + return $this; + } } diff --git a/src/Illuminate/Cache/NoLock.php b/src/Illuminate/Cache/NoLock.php new file mode 100644 index 0000000000000000000000000000000000000000..68560f8f83d3400a3eb3dd328aaa515ad4158641 --- /dev/null +++ b/src/Illuminate/Cache/NoLock.php @@ -0,0 +1,46 @@ +<?php + +namespace Illuminate\Cache; + +class NoLock extends Lock +{ + /** + * Attempt to acquire the lock. + * + * @return bool + */ + public function acquire() + { + return true; + } + + /** + * Release the lock. + * + * @return bool + */ + public function release() + { + return true; + } + + /** + * Releases this lock in disregard of ownership. + * + * @return void + */ + public function forceRelease() + { + // + } + + /** + * Returns the owner value written into the driver for this lock. + * + * @return mixed + */ + protected function getCurrentOwner() + { + return $this->owner; + } +} diff --git a/src/Illuminate/Cache/NullStore.php b/src/Illuminate/Cache/NullStore.php index 43231b492347f9eeee43194ceb5527f86ed5d490..5694e6c6755bcb26ff92fc69ef14b2fdd6bc31f4 100755 --- a/src/Illuminate/Cache/NullStore.php +++ b/src/Illuminate/Cache/NullStore.php @@ -2,7 +2,9 @@ namespace Illuminate\Cache; -class NullStore extends TaggableStore +use Illuminate\Contracts\Cache\LockProvider; + +class NullStore extends TaggableStore implements LockProvider { use RetrievesMultipleKeys; @@ -10,7 +12,7 @@ class NullStore extends TaggableStore * Retrieve an item from the cache by key. * * @param string $key - * @return mixed + * @return void */ public function get($key) { @@ -35,7 +37,7 @@ class NullStore extends TaggableStore * * @param string $key * @param mixed $value - * @return int|bool + * @return bool */ public function increment($key, $value = 1) { @@ -47,7 +49,7 @@ class NullStore extends TaggableStore * * @param string $key * @param mixed $value - * @return int|bool + * @return bool */ public function decrement($key, $value = 1) { @@ -66,6 +68,31 @@ class NullStore extends TaggableStore return false; } + /** + * Get a lock instance. + * + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function lock($name, $seconds = 0, $owner = null) + { + return new NoLock($name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @param string $name + * @param string $owner + * @return \Illuminate\Contracts\Cache\Lock + */ + public function restoreLock($name, $owner) + { + return $this->lock($name, 0, $owner); + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/PhpRedisLock.php b/src/Illuminate/Cache/PhpRedisLock.php new file mode 100644 index 0000000000000000000000000000000000000000..6cfce7938a3763281c238e7611a6f1064a8c24bf --- /dev/null +++ b/src/Illuminate/Cache/PhpRedisLock.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Cache; + +use Illuminate\Redis\Connections\PhpRedisConnection; + +class PhpRedisLock extends RedisLock +{ + /** + * Create a new phpredis lock instance. + * + * @param \Illuminate\Redis\Connections\PhpRedisConnection $redis + * @param string $name + * @param int $seconds + * @param string|null $owner + * @return void + */ + public function __construct(PhpRedisConnection $redis, string $name, int $seconds, ?string $owner = null) + { + parent::__construct($redis, $name, $seconds, $owner); + } + + /** + * {@inheritDoc} + */ + public function release() + { + return (bool) $this->redis->eval( + LuaScripts::releaseLock(), + 1, + $this->name, + ...$this->redis->pack([$this->owner]) + ); + } +} diff --git a/src/Illuminate/Cache/RateLimiter.php b/src/Illuminate/Cache/RateLimiter.php index efa83d4fc01c08b98da4eb57326c9f765b748d21..3786e90cfd69ee9679e9637cff8d0ab03687bf4f 100644 --- a/src/Illuminate/Cache/RateLimiter.php +++ b/src/Illuminate/Cache/RateLimiter.php @@ -2,6 +2,7 @@ namespace Illuminate\Cache; +use Closure; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Support\InteractsWithTime; @@ -16,6 +17,13 @@ class RateLimiter */ protected $cache; + /** + * The configured limit object resolvers. + * + * @var array + */ + protected $limiters = []; + /** * Create a new rate limiter instance. * @@ -27,6 +35,51 @@ class RateLimiter $this->cache = $cache; } + /** + * Register a named limiter configuration. + * + * @param string $name + * @param \Closure $callback + * @return $this + */ + public function for(string $name, Closure $callback) + { + $this->limiters[$name] = $callback; + + return $this; + } + + /** + * Get the given named rate limiter. + * + * @param string $name + * @return \Closure + */ + public function limiter(string $name) + { + return $this->limiters[$name] ?? null; + } + + /** + * Attempts to execute a callback if it's not limited. + * + * @param string $key + * @param int $maxAttempts + * @param \Closure $callback + * @param int $decaySeconds + * @return mixed + */ + public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60) + { + if ($this->tooManyAttempts($key, $maxAttempts)) { + return false; + } + + return tap($callback() ?: true, function () use ($key, $decaySeconds) { + $this->hit($key, $decaySeconds); + }); + } + /** * Determine if the given key has been "accessed" too many times. * @@ -36,6 +89,8 @@ class RateLimiter */ public function tooManyAttempts($key, $maxAttempts) { + $key = $this->cleanRateLimiterKey($key); + if ($this->attempts($key) >= $maxAttempts) { if ($this->cache->has($key.':timer')) { return true; @@ -56,6 +111,8 @@ class RateLimiter */ public function hit($key, $decaySeconds = 60) { + $key = $this->cleanRateLimiterKey($key); + $this->cache->add( $key.':timer', $this->availableAt($decaySeconds), $decaySeconds ); @@ -79,6 +136,8 @@ class RateLimiter */ public function attempts($key) { + $key = $this->cleanRateLimiterKey($key); + return $this->cache->get($key, 0); } @@ -90,6 +149,8 @@ class RateLimiter */ public function resetAttempts($key) { + $key = $this->cleanRateLimiterKey($key); + return $this->cache->forget($key); } @@ -100,13 +161,27 @@ class RateLimiter * @param int $maxAttempts * @return int */ - public function retriesLeft($key, $maxAttempts) + public function remaining($key, $maxAttempts) { + $key = $this->cleanRateLimiterKey($key); + $attempts = $this->attempts($key); return $maxAttempts - $attempts; } + /** + * Get the number of retries left for the given key. + * + * @param string $key + * @param int $maxAttempts + * @return int + */ + public function retriesLeft($key, $maxAttempts) + { + return $this->remaining($key, $maxAttempts); + } + /** * Clear the hits and lockout timer for the given key. * @@ -115,6 +190,8 @@ class RateLimiter */ public function clear($key) { + $key = $this->cleanRateLimiterKey($key); + $this->resetAttempts($key); $this->cache->forget($key.':timer'); @@ -128,6 +205,19 @@ class RateLimiter */ public function availableIn($key) { - return $this->cache->get($key.':timer') - $this->currentTime(); + $key = $this->cleanRateLimiterKey($key); + + return max(0, $this->cache->get($key.':timer') - $this->currentTime()); + } + + /** + * Clean the rate limiter key from unicode characters. + * + * @param string $key + * @return string + */ + public function cleanRateLimiterKey($key) + { + return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key)); } } diff --git a/src/Illuminate/Cache/RateLimiting/GlobalLimit.php b/src/Illuminate/Cache/RateLimiting/GlobalLimit.php new file mode 100644 index 0000000000000000000000000000000000000000..4f084eb1095f63f86be3561c4aa2a6aabae710ee --- /dev/null +++ b/src/Illuminate/Cache/RateLimiting/GlobalLimit.php @@ -0,0 +1,18 @@ +<?php + +namespace Illuminate\Cache\RateLimiting; + +class GlobalLimit extends Limit +{ + /** + * Create a new limit instance. + * + * @param int $maxAttempts + * @param int $decayMinutes + * @return void + */ + public function __construct(int $maxAttempts, int $decayMinutes = 1) + { + parent::__construct('', $maxAttempts, $decayMinutes); + } +} diff --git a/src/Illuminate/Cache/RateLimiting/Limit.php b/src/Illuminate/Cache/RateLimiting/Limit.php new file mode 100644 index 0000000000000000000000000000000000000000..330cab39bba1f41e214586ea23b984bbfa96be23 --- /dev/null +++ b/src/Illuminate/Cache/RateLimiting/Limit.php @@ -0,0 +1,132 @@ +<?php + +namespace Illuminate\Cache\RateLimiting; + +class Limit +{ + /** + * The rate limit signature key. + * + * @var mixed|string + */ + public $key; + + /** + * The maximum number of attempts allowed within the given number of minutes. + * + * @var int + */ + public $maxAttempts; + + /** + * The number of minutes until the rate limit is reset. + * + * @var int + */ + public $decayMinutes; + + /** + * The response generator callback. + * + * @var callable + */ + public $responseCallback; + + /** + * Create a new limit instance. + * + * @param mixed|string $key + * @param int $maxAttempts + * @param int $decayMinutes + * @return void + */ + public function __construct($key = '', int $maxAttempts = 60, int $decayMinutes = 1) + { + $this->key = $key; + $this->maxAttempts = $maxAttempts; + $this->decayMinutes = $decayMinutes; + } + + /** + * Create a new rate limit. + * + * @param int $maxAttempts + * @return static + */ + public static function perMinute($maxAttempts) + { + return new static('', $maxAttempts); + } + + /** + * Create a new rate limit using minutes as decay time. + * + * @param int $decayMinutes + * @param int $maxAttempts + * @return static + */ + public static function perMinutes($decayMinutes, $maxAttempts) + { + return new static('', $maxAttempts, $decayMinutes); + } + + /** + * Create a new rate limit using hours as decay time. + * + * @param int $maxAttempts + * @param int $decayHours + * @return static + */ + public static function perHour($maxAttempts, $decayHours = 1) + { + return new static('', $maxAttempts, 60 * $decayHours); + } + + /** + * Create a new rate limit using days as decay time. + * + * @param int $maxAttempts + * @param int $decayDays + * @return static + */ + public static function perDay($maxAttempts, $decayDays = 1) + { + return new static('', $maxAttempts, 60 * 24 * $decayDays); + } + + /** + * Create a new unlimited rate limit. + * + * @return static + */ + public static function none() + { + return new Unlimited; + } + + /** + * Set the key of the rate limit. + * + * @param string $key + * @return $this + */ + public function by($key) + { + $this->key = $key; + + return $this; + } + + /** + * Set the callback that should generate the response when the limit is exceeded. + * + * @param callable $callback + * @return $this + */ + public function response(callable $callback) + { + $this->responseCallback = $callback; + + return $this; + } +} diff --git a/src/Illuminate/Cache/RateLimiting/Unlimited.php b/src/Illuminate/Cache/RateLimiting/Unlimited.php new file mode 100644 index 0000000000000000000000000000000000000000..fcfaa3178f0c5ee62f820a8318baf7f2b7293bba --- /dev/null +++ b/src/Illuminate/Cache/RateLimiting/Unlimited.php @@ -0,0 +1,16 @@ +<?php + +namespace Illuminate\Cache\RateLimiting; + +class Unlimited extends GlobalLimit +{ + /** + * Create a new limit instance. + * + * @return void + */ + public function __construct() + { + parent::__construct(PHP_INT_MAX); + } +} diff --git a/src/Illuminate/Cache/RedisLock.php b/src/Illuminate/Cache/RedisLock.php index 9f62eada9510664a90c3af1f139a446829e5c199..481b811d398f0a512338ac90fa1f335261f3626c 100644 --- a/src/Illuminate/Cache/RedisLock.php +++ b/src/Illuminate/Cache/RedisLock.php @@ -70,4 +70,14 @@ class RedisLock extends Lock { return $this->redis->get($this->name); } + + /** + * Get the name of the Redis connection being used to manage the lock. + * + * @return string + */ + public function getConnectionName() + { + return $this->redis->getName(); + } } diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index f3aa8a3dce34065f70f5d5a00dbd4dbf7288cd88..4896c9183d03ce644c5af7c7561bd17bd8ef5baf 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -4,6 +4,7 @@ namespace Illuminate\Cache; use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Redis\Factory as Redis; +use Illuminate\Redis\Connections\PhpRedisConnection; class RedisStore extends TaggableStore implements LockProvider { @@ -22,12 +23,19 @@ class RedisStore extends TaggableStore implements LockProvider protected $prefix; /** - * The Redis connection that should be used. + * The Redis connection instance that should be used to manage locks. * * @var string */ protected $connection; + /** + * The name of the connection that should be used for locks. + * + * @var string + */ + protected $lockConnection; + /** * Create a new Redis store. * @@ -181,7 +189,15 @@ class RedisStore extends TaggableStore implements LockProvider */ public function lock($name, $seconds = 0, $owner = null) { - return new RedisLock($this->connection(), $this->prefix.$name, $seconds, $owner); + $lockName = $this->prefix.$name; + + $lockConnection = $this->lockConnection(); + + if ($lockConnection instanceof PhpRedisConnection) { + return new PhpRedisLock($lockConnection, $lockName, $seconds, $owner); + } + + return new RedisLock($lockConnection, $lockName, $seconds, $owner); } /** @@ -243,7 +259,17 @@ class RedisStore extends TaggableStore implements LockProvider } /** - * Set the connection name to be used. + * Get the Redis connection instance that should be used to manage locks. + * + * @return \Illuminate\Redis\Connections\Connection + */ + public function lockConnection() + { + return $this->redis->connection($this->lockConnection ?? $this->connection); + } + + /** + * Specify the name of the connection that should be used to store data. * * @param string $connection * @return void @@ -253,6 +279,19 @@ class RedisStore extends TaggableStore implements LockProvider $this->connection = $connection; } + /** + * Specify the name of the connection that should be used to manage locks. + * + * @param string $connection + * @return $this + */ + public function setLockConnection($connection) + { + $this->lockConnection = $connection; + + return $this; + } + /** * Get the Redis database instance. * diff --git a/src/Illuminate/Cache/RedisTaggedCache.php b/src/Illuminate/Cache/RedisTaggedCache.php index ad50ce9adb419bb75001b5b533e58c41ea3728d2..7863dbc0a60a07dfeec2853b718e59d517891f89 100644 --- a/src/Illuminate/Cache/RedisTaggedCache.php +++ b/src/Illuminate/Cache/RedisTaggedCache.php @@ -10,6 +10,7 @@ class RedisTaggedCache extends TaggedCache * @var string */ const REFERENCE_KEY_FOREVER = 'forever_ref'; + /** * Standard reference key. * @@ -41,13 +42,13 @@ class RedisTaggedCache extends TaggedCache * * @param string $key * @param mixed $value - * @return void + * @return int|bool */ public function increment($key, $value = 1) { $this->pushStandardKeys($this->tags->getNamespace(), $key); - parent::increment($key, $value); + return parent::increment($key, $value); } /** @@ -55,13 +56,13 @@ class RedisTaggedCache extends TaggedCache * * @param string $key * @param mixed $value - * @return void + * @return int|bool */ public function decrement($key, $value = 1) { $this->pushStandardKeys($this->tags->getNamespace(), $key); - parent::decrement($key, $value); + return parent::decrement($key, $value); } /** @@ -88,7 +89,9 @@ class RedisTaggedCache extends TaggedCache $this->deleteForeverKeys(); $this->deleteStandardKeys(); - return parent::flush(); + $this->tags->flush(); + + return true; } /** @@ -175,13 +178,26 @@ class RedisTaggedCache extends TaggedCache */ protected function deleteValues($referenceKey) { - $values = array_unique($this->store->connection()->smembers($referenceKey)); + $cursor = $defaultCursorValue = '0'; + + do { + [$cursor, $valuesChunk] = $this->store->connection()->sscan( + $referenceKey, $cursor, ['match' => '*', 'count' => 1000] + ); + + // PhpRedis client returns false if set does not exist or empty. Array destruction + // on false stores null in each variable. If valuesChunk is null, it means that + // there were not results from the previously executed "sscan" Redis command. + if (is_null($valuesChunk)) { + break; + } + + $valuesChunk = array_unique($valuesChunk); - if (count($values) > 0) { - foreach (array_chunk($values, 1000) as $valuesChunk) { + if (count($valuesChunk) > 0) { $this->store->connection()->del(...$valuesChunk); } - } + } while (((string) $cursor) !== $defaultCursorValue); } /** diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index 0b3418a90d23210fed8ee34f03f13c79b20f5675..c7934c034b94042eda03a74bdd023c7e2b0a1766 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -131,6 +131,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return iterable */ public function getMultiple($keys, $default = null) { @@ -219,6 +221,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return bool */ public function set($key, $value, $ttl = null) { @@ -276,6 +280,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return bool */ public function setMultiple($values, $ttl = null) { @@ -292,8 +298,12 @@ class Repository implements ArrayAccess, CacheContract */ public function add($key, $value, $ttl = null) { + $seconds = null; + if ($ttl !== null) { - if ($this->getSeconds($ttl) <= 0) { + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { return false; } @@ -301,8 +311,6 @@ class Repository implements ArrayAccess, CacheContract // has a chance to override this logic. Some drivers better support the way // this operation should work with a total "atomic" implementation of it. if (method_exists($this->store, 'add')) { - $seconds = $this->getSeconds($ttl); - return $this->store->add( $this->itemKey($key), $value, $seconds ); @@ -313,7 +321,7 @@ class Repository implements ArrayAccess, CacheContract // so it exists for subsequent requests. Then, we will return true so it is // easy to know if the value gets added. Otherwise, we will return false. if (is_null($this->get($key))) { - return $this->put($key, $value, $ttl); + return $this->put($key, $value, $seconds); } return false; @@ -365,7 +373,7 @@ class Repository implements ArrayAccess, CacheContract * Get an item from the cache, or execute the given Closure and store the result. * * @param string $key - * @param \DateTimeInterface|\DateInterval|int|null $ttl + * @param \Closure|\DateTimeInterface|\DateInterval|int|null $ttl * @param \Closure $callback * @return mixed */ @@ -380,7 +388,7 @@ class Repository implements ArrayAccess, CacheContract return $value; } - $this->put($key, $value = $callback(), $ttl); + $this->put($key, $value = $callback(), value($ttl)); return $value; } @@ -437,6 +445,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return bool */ public function delete($key) { @@ -445,6 +455,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return bool */ public function deleteMultiple($keys) { @@ -461,6 +473,8 @@ class Repository implements ArrayAccess, CacheContract /** * {@inheritdoc} + * + * @return bool */ public function clear() { @@ -477,7 +491,7 @@ class Repository implements ArrayAccess, CacheContract */ public function tags($names) { - if (! method_exists($this->store, 'tags')) { + if (! $this->supportsTags()) { throw new BadMethodCallException('This cache store does not support tagging.'); } @@ -501,6 +515,33 @@ class Repository implements ArrayAccess, CacheContract return $key; } + /** + * Calculate the number of seconds for the given TTL. + * + * @param \DateTimeInterface|\DateInterval|int $ttl + * @return int + */ + protected function getSeconds($ttl) + { + $duration = $this->parseDateInterval($ttl); + + if ($duration instanceof DateTimeInterface) { + $duration = Carbon::now()->diffInRealSeconds($duration, false); + } + + return (int) ($duration > 0 ? $duration : 0); + } + + /** + * Determine if the current store supports tags. + * + * @return bool + */ + public function supportsTags() + { + return method_exists($this->store, 'tags'); + } + /** * Get the default cache time. * @@ -537,7 +578,7 @@ class Repository implements ArrayAccess, CacheContract /** * Fire an event for this cache instance. * - * @param string $event + * @param object|string $event * @return void */ protected function event($event) @@ -550,7 +591,7 @@ class Repository implements ArrayAccess, CacheContract /** * Get the event dispatcher instance. * - * @return \Illuminate\Contracts\Events\Dispatcher + * @return \Illuminate\Contracts\Events\Dispatcher */ public function getEventDispatcher() { @@ -574,6 +615,7 @@ class Repository implements ArrayAccess, CacheContract * @param string $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return $this->has($key); @@ -585,6 +627,7 @@ class Repository implements ArrayAccess, CacheContract * @param string $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->get($key); @@ -597,6 +640,7 @@ class Repository implements ArrayAccess, CacheContract * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->put($key, $value, $this->default); @@ -608,28 +652,12 @@ class Repository implements ArrayAccess, CacheContract * @param string $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { $this->forget($key); } - /** - * Calculate the number of seconds for the given TTL. - * - * @param \DateTimeInterface|\DateInterval|int $ttl - * @return int - */ - protected function getSeconds($ttl) - { - $duration = $this->parseDateInterval($ttl); - - if ($duration instanceof DateTimeInterface) { - $duration = Carbon::now()->diffInRealSeconds($duration, false); - } - - return (int) $duration > 0 ? $duration : 0; - } - /** * Handle dynamic calls into macros or pass missing methods to the store. * diff --git a/src/Illuminate/Cache/RetrievesMultipleKeys.php b/src/Illuminate/Cache/RetrievesMultipleKeys.php index 5dd41edb5e7f416119ae336ccec74c28c43628ce..7db7a0aa50afc19247ba252998a705e3af68d3bd 100644 --- a/src/Illuminate/Cache/RetrievesMultipleKeys.php +++ b/src/Illuminate/Cache/RetrievesMultipleKeys.php @@ -16,8 +16,12 @@ trait RetrievesMultipleKeys { $return = []; - foreach ($keys as $key) { - $return[$key] = $this->get($key); + $keys = collect($keys)->mapWithKeys(function ($value, $key) { + return [is_string($key) ? $key : $value => is_string($key) ? $value : null]; + })->all(); + + foreach ($keys as $key => $default) { + $return[$key] = $this->get($key, $default); } return $return; diff --git a/src/Illuminate/Cache/TagSet.php b/src/Illuminate/Cache/TagSet.php index 214d648157e6004575fdd0b2ae9b7d72718e99d1..471dc679ce5fb1be4dba013bb424e25aaa730d81 100644 --- a/src/Illuminate/Cache/TagSet.php +++ b/src/Illuminate/Cache/TagSet.php @@ -56,6 +56,26 @@ class TagSet return $id; } + /** + * Flush all the tags in the set. + * + * @return void + */ + public function flush() + { + array_walk($this->names, [$this, 'flushTag']); + } + + /** + * Flush the tag from the cache. + * + * @param string $name + */ + public function flushTag($name) + { + $this->store->forget($this->tagKey($name)); + } + /** * Get a unique namespace that changes when any of the tags are flushed. * diff --git a/src/Illuminate/Cache/TaggedCache.php b/src/Illuminate/Cache/TaggedCache.php index 01e483b6ea66a8ed635f40051fca629d749c4ac0..7cd12303882c15e88a79020e408fa931aebcf816 100644 --- a/src/Illuminate/Cache/TaggedCache.php +++ b/src/Illuminate/Cache/TaggedCache.php @@ -52,11 +52,11 @@ class TaggedCache extends Repository * * @param string $key * @param mixed $value - * @return void + * @return int|bool */ public function increment($key, $value = 1) { - $this->store->increment($this->itemKey($key), $value); + return $this->store->increment($this->itemKey($key), $value); } /** @@ -64,11 +64,11 @@ class TaggedCache extends Repository * * @param string $key * @param mixed $value - * @return void + * @return int|bool */ public function decrement($key, $value = 1) { - $this->store->decrement($this->itemKey($key), $value); + return $this->store->decrement($this->itemKey($key), $value); } /** @@ -105,7 +105,7 @@ class TaggedCache extends Repository /** * Fire an event for this cache instance. * - * @param string $event + * @param \Illuminate\Cache\Events\CacheEvent $event * @return void */ protected function event($event) diff --git a/src/Illuminate/Cache/composer.json b/src/Illuminate/Cache/composer.json index 01331f1b0f2a37334e3a85ee2d8efc5a5e6fc7cf..69f553fa6373bf1271086ebaf4179916b7658f7b 100755 --- a/src/Illuminate/Cache/composer.json +++ b/src/Illuminate/Cache/composer.json @@ -14,9 +14,14 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0" + }, + "provide": { + "psr/simple-cache-implementation": "1.0" }, "autoload": { "psr-4": { @@ -25,15 +30,15 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { "ext-memcached": "Required to use the memcache cache driver.", - "illuminate/database": "Required to use the database cache driver (^6.0).", - "illuminate/filesystem": "Required to use the file cache driver (^6.0).", - "illuminate/redis": "Required to use the redis cache driver (^6.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^4.3.4)." + "illuminate/database": "Required to use the database cache driver (^8.0).", + "illuminate/filesystem": "Required to use the file cache driver (^8.0).", + "illuminate/redis": "Required to use the redis cache driver (^8.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^5.4)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Support/Arr.php b/src/Illuminate/Collections/Arr.php old mode 100755 new mode 100644 similarity index 84% rename from src/Illuminate/Support/Arr.php rename to src/Illuminate/Collections/Arr.php index bf30467df51fa2cb9b2d4356c69d3c55f92a9d88..fd7dca8a586adf5d3f373d3530aae84f3d1d32a3 --- a/src/Illuminate/Support/Arr.php +++ b/src/Illuminate/Collections/Arr.php @@ -121,6 +121,23 @@ class Arr return $results; } + /** + * Convert a flatten "dot" notation array into an expanded array. + * + * @param iterable $array + * @return array + */ + public static function undot($array) + { + $results = []; + + foreach ($array as $key => $value) { + static::set($results, $key, $value); + } + + return $results; + } + /** * Get all of the given array except for a specified array of keys. * @@ -144,6 +161,10 @@ class Arr */ public static function exists($array, $key) { + if ($array instanceof Enumerable) { + return $array->has($key); + } + if ($array instanceof ArrayAccess) { return $array->offsetExists($key); } @@ -389,6 +410,19 @@ class Arr return array_keys($keys) !== $keys; } + /** + * Determines if an array is a list. + * + * An array is a "list" if all array keys are sequential integers starting from 0 with no gaps in between. + * + * @param array $array + * @return bool + */ + public static function isList($array) + { + return ! self::isAssoc($array); + } + /** * Get a subset of the items from the given array. * @@ -405,7 +439,7 @@ class Arr * Pluck an array of values from an array. * * @param iterable $array - * @param string|array $value + * @param string|array|int|null $value * @param string|array|null $key * @return array */ @@ -463,7 +497,7 @@ class Arr */ public static function prepend($array, $value, $key = null) { - if (is_null($key)) { + if (func_num_args() == 2) { array_unshift($array, $value); } else { $array = [$key => $value] + $array; @@ -476,7 +510,7 @@ class Arr * Get a value from the array, and remove it. * * @param array $array - * @param string $key + * @param string|int $key * @param mixed $default * @return mixed */ @@ -489,16 +523,28 @@ class Arr return $value; } + /** + * Convert the array into a query string. + * + * @param array $array + * @return string + */ + public static function query($array) + { + return http_build_query($array, '', '&', PHP_QUERY_RFC3986); + } + /** * Get one or a specified number of random values from an array. * * @param array $array * @param int|null $number + * @param bool|false $preserveKeys * @return mixed * * @throws \InvalidArgumentException */ - public static function random($array, $number = null) + public static function random($array, $number = null, $preserveKeys = false) { $requested = is_null($number) ? 1 : $number; @@ -522,8 +568,14 @@ class Arr $results = []; - foreach ((array) $keys as $key) { - $results[] = $array[$key]; + if ($preserveKeys) { + foreach ((array) $keys as $key) { + $results[$key] = $array[$key]; + } + } else { + foreach ((array) $keys as $key) { + $results[] = $array[$key]; + } } return $results; @@ -535,7 +587,7 @@ class Arr * If no key is given to the method, the entire array will be replaced. * * @param array $array - * @param string $key + * @param string|null $key * @param mixed $value * @return array */ @@ -547,8 +599,12 @@ class Arr $keys = explode('.', $key); - while (count($keys) > 1) { - $key = array_shift($keys); + foreach ($keys as $i => $key) { + if (count($keys) === 1) { + break; + } + + unset($keys[$i]); // If the key doesn't exist at this depth, we will just create an empty array // to hold the next value, allowing us to create the arrays to hold final @@ -589,7 +645,7 @@ class Arr * Sort the array using the given callback or "dot" notation. * * @param array $array - * @param callable|string|null $callback + * @param callable|array|string|null $callback * @return array */ public static function sort($array, $callback = null) @@ -601,34 +657,52 @@ class Arr * Recursively sort an array by keys and values. * * @param array $array + * @param int $options + * @param bool $descending * @return array */ - public static function sortRecursive($array) + public static function sortRecursive($array, $options = SORT_REGULAR, $descending = false) { foreach ($array as &$value) { if (is_array($value)) { - $value = static::sortRecursive($value); + $value = static::sortRecursive($value, $options, $descending); } } if (static::isAssoc($array)) { - ksort($array); + $descending + ? krsort($array, $options) + : ksort($array, $options); } else { - sort($array); + $descending + ? rsort($array, $options) + : sort($array, $options); } return $array; } /** - * Convert the array into a query string. + * Conditionally compile classes from an array into a CSS class list. * * @param array $array * @return string */ - public static function query($array) + public static function toCssClasses($array) { - return http_build_query($array, '', '&', PHP_QUERY_RFC3986); + $classList = static::wrap($array); + + $classes = []; + + foreach ($classList as $class => $constraint) { + if (is_numeric($class)) { + $classes[] = $constraint; + } elseif ($constraint) { + $classes[] = $class; + } + } + + return implode(' ', $classes); } /** @@ -643,6 +717,19 @@ class Arr return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH); } + /** + * Filter items where the value is not null. + * + * @param array $array + * @return array + */ + public static function whereNotNull($array) + { + return static::where($array, function ($value) { + return ! is_null($value); + }); + } + /** * If the given value is not an array and not null, wrap it in one. * diff --git a/src/Illuminate/Support/Collection.php b/src/Illuminate/Collections/Collection.php similarity index 72% rename from src/Illuminate/Support/Collection.php rename to src/Illuminate/Collections/Collection.php index b9d4ebb7d82b1bd288009f14dd388a0f54d04d18..61a48841c1128a2fb74e694c355f59264f31dfb5 100644 --- a/src/Illuminate/Support/Collection.php +++ b/src/Illuminate/Collections/Collection.php @@ -4,11 +4,12 @@ namespace Illuminate\Support; use ArrayAccess; use ArrayIterator; +use Illuminate\Contracts\Support\CanBeEscapedWhenCastToString; use Illuminate\Support\Traits\EnumeratesValues; use Illuminate\Support\Traits\Macroable; use stdClass; -class Collection implements ArrayAccess, Enumerable +class Collection implements ArrayAccess, CanBeEscapedWhenCastToString, Enumerable { use EnumeratesValues, Macroable; @@ -31,23 +32,15 @@ class Collection implements ArrayAccess, Enumerable } /** - * Create a new collection by invoking the callback a given amount of times. + * Create a collection with the given range. * - * @param int $number - * @param callable $callback + * @param int $from + * @param int $to * @return static */ - public static function times($number, callable $callback = null) + public static function range($from, $to) { - if ($number < 1) { - return new static; - } - - if (is_null($callback)) { - return new static(range(1, $number)); - } - - return (new static(range(1, $number)))->map($callback); + return new static(range($from, $to)); } /** @@ -135,7 +128,7 @@ class Collection implements ArrayAccess, Enumerable $collection = isset($key) ? $this->pluck($key) : $this; - $counts = new self; + $counts = new static; $collection->each(function ($value) use ($counts) { $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1; @@ -183,6 +176,19 @@ class Collection implements ArrayAccess, Enumerable return $this->contains($this->operatorForWhere(...func_get_args())); } + /** + * Determine if an item is not contained in the collection. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return bool + */ + public function doesntContain($key, $operator = null, $value = null) + { + return ! $this->contains(...func_get_args()); + } + /** * Cross join with the given lists, returning all possible permutations. * @@ -268,7 +274,7 @@ class Collection implements ArrayAccess, Enumerable /** * Retrieve duplicate items from the collection. * - * @param callable|null $callback + * @param callable|string|null $callback * @param bool $strict * @return static */ @@ -296,7 +302,7 @@ class Collection implements ArrayAccess, Enumerable /** * Retrieve duplicate items from the collection using strict comparison. * - * @param callable|null $callback + * @param callable|string|null $callback * @return static */ public function duplicatesStrict($callback = null) @@ -391,7 +397,7 @@ class Collection implements ArrayAccess, Enumerable /** * Remove an item from the collection by key. * - * @param string|array $keys + * @param string|int|array $keys * @return $this */ public function forget($keys) @@ -412,13 +418,31 @@ class Collection implements ArrayAccess, Enumerable */ public function get($key, $default = null) { - if ($this->offsetExists($key)) { + if (array_key_exists($key, $this->items)) { return $this->items[$key]; } return value($default); } + /** + * Get an item from the collection by key or add it to collection if it does not exist. + * + * @param mixed $key + * @param mixed $value + * @return mixed + */ + public function getOrPut($key, $value) + { + if (array_key_exists($key, $this->items)) { + return $this->items[$key]; + } + + $this->offsetSet($key, $value = value($value)); + + return $value; + } + /** * Group an associative array by a field or using a callback. * @@ -501,7 +525,7 @@ class Collection implements ArrayAccess, Enumerable $keys = is_array($key) ? $key : func_get_args(); foreach ($keys as $value) { - if (! $this->offsetExists($value)) { + if (! array_key_exists($value, $this->items)) { return false; } } @@ -509,22 +533,45 @@ class Collection implements ArrayAccess, Enumerable return true; } + /** + * Determine if any of the keys exist in the collection. + * + * @param mixed $key + * @return bool + */ + public function hasAny($key) + { + if ($this->isEmpty()) { + return false; + } + + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $value) { + if ($this->has($value)) { + return true; + } + } + + return false; + } + /** * Concatenate values of a given key as a string. * * @param string $value - * @param string $glue + * @param string|null $glue * @return string */ public function implode($value, $glue = null) { $first = $this->first(); - if (is_array($first) || is_object($first)) { - return implode($glue, $this->pluck($value)->all()); + if (is_array($first) || (is_object($first) && ! $first instanceof Stringable)) { + return implode($glue ?? '', $this->pluck($value)->all()); } - return implode($value, $this->items); + return implode($value ?? '', $this->items); } /** @@ -561,6 +608,16 @@ class Collection implements ArrayAccess, Enumerable return empty($this->items); } + /** + * Determine if the collection contains a single item. + * + * @return bool + */ + public function containsOneItem() + { + return $this->count() === 1; + } + /** * Join all items from the collection using a string. The final items can use a separate glue string. * @@ -616,7 +673,7 @@ class Collection implements ArrayAccess, Enumerable /** * Get the values of a given key. * - * @param string|array $value + * @param string|array|int|null $value * @param string|null $key * @return static */ @@ -749,8 +806,8 @@ class Collection implements ArrayAccess, Enumerable $position = 0; - foreach ($this->items as $item) { - if ($position % $step === $offset) { + foreach ($this->slice($offset)->items as $item) { + if ($position % $step === 0) { $new[] = $item; } @@ -782,13 +839,30 @@ class Collection implements ArrayAccess, Enumerable } /** - * Get and remove the last item from the collection. + * Get and remove the last N items from the collection. * + * @param int $count * @return mixed */ - public function pop() + public function pop($count = 1) { - return array_pop($this->items); + if ($count === 1) { + return array_pop($this->items); + } + + if ($this->isEmpty()) { + return new static; + } + + $results = []; + + $collectionCount = $this->count(); + + foreach (range(1, min($count, $collectionCount)) as $item) { + array_push($results, array_pop($this->items)); + } + + return new static($results); } /** @@ -800,20 +874,22 @@ class Collection implements ArrayAccess, Enumerable */ public function prepend($value, $key = null) { - $this->items = Arr::prepend($this->items, $value, $key); + $this->items = Arr::prepend($this->items, ...func_get_args()); return $this; } /** - * Push an item onto the end of the collection. + * Push one or more items onto the end of the collection. * - * @param mixed $value + * @param mixed $values * @return $this */ - public function push($value) + public function push(...$values) { - $this->items[] = $value; + foreach ($values as $value) { + $this->items[] = $value; + } return $this; } @@ -878,18 +954,6 @@ class Collection implements ArrayAccess, Enumerable return new static(Arr::random($this->items, $number)); } - /** - * Reduce the collection to a single value. - * - * @param callable $callback - * @param mixed $initial - * @return mixed - */ - public function reduce(callable $callback, $initial = null) - { - return array_reduce($this->items, $callback, $initial); - } - /** * Replace the collection items with the given items. * @@ -945,19 +1009,36 @@ class Collection implements ArrayAccess, Enumerable } /** - * Get and remove the first item from the collection. + * Get and remove the first N items from the collection. * + * @param int $count * @return mixed */ - public function shift() + public function shift($count = 1) { - return array_shift($this->items); + if ($count === 1) { + return array_shift($this->items); + } + + if ($this->isEmpty()) { + return new static; + } + + $results = []; + + $collectionCount = $this->count(); + + foreach (range(1, min($count, $collectionCount)) as $item) { + array_push($results, array_shift($this->items)); + } + + return new static($results); } /** * Shuffle the items in the collection. * - * @param int $seed + * @param int|null $seed * @return static */ public function shuffle($seed = null) @@ -965,6 +1046,22 @@ class Collection implements ArrayAccess, Enumerable return new static(Arr::shuffle($this->items, $seed)); } + /** + * Create chunks representing a "sliding window" view of the items in the collection. + * + * @param int $size + * @param int $step + * @return static + */ + public function sliding($size = 2, $step = 1) + { + $chunks = floor(($this->count() - $size) / $step) + 1; + + return static::times($chunks, function ($number) use ($size, $step) { + return $this->slice(($number - 1) * $step, $size); + }); + } + /** * Skip the first {$count} items. * @@ -976,11 +1073,33 @@ class Collection implements ArrayAccess, Enumerable return $this->slice($count); } + /** + * Skip items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipUntil($value) + { + return new static($this->lazy()->skipUntil($value)->all()); + } + + /** + * Skip items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipWhile($value) + { + return new static($this->lazy()->skipWhile($value)->all()); + } + /** * Slice the underlying collection array. * * @param int $offset - * @param int $length + * @param int|null $length * @return static */ public function slice($offset, $length = null) @@ -1025,6 +1144,74 @@ class Collection implements ArrayAccess, Enumerable return $groups; } + /** + * Split a collection into a certain number of groups, and fill the first groups completely. + * + * @param int $numberOfGroups + * @return static + */ + public function splitIn($numberOfGroups) + { + return $this->chunk(ceil($this->count() / $numberOfGroups)); + } + + /** + * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return mixed + * + * @throws \Illuminate\Support\ItemNotFoundException + * @throws \Illuminate\Support\MultipleItemsFoundException + */ + public function sole($key = null, $operator = null, $value = null) + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + $items = $this->when($filter)->filter($filter); + + if ($items->isEmpty()) { + throw new ItemNotFoundException; + } + + if ($items->count() > 1) { + throw new MultipleItemsFoundException; + } + + return $items->first(); + } + + /** + * Get the first item in the collection but throw an exception if no matching items exist. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return mixed + * + * @throws \Illuminate\Support\ItemNotFoundException + */ + public function firstOrFail($key = null, $operator = null, $value = null) + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + $placeholder = new stdClass(); + + $item = $this->first($filter, $placeholder); + + if ($item === $placeholder) { + throw new ItemNotFoundException; + } + + return $item; + } + /** * Chunk the collection into chunks of the given size. * @@ -1046,19 +1233,47 @@ class Collection implements ArrayAccess, Enumerable return new static($chunks); } + /** + * Chunk the collection into chunks with a callback. + * + * @param callable $callback + * @return static + */ + public function chunkWhile(callable $callback) + { + return new static( + $this->lazy()->chunkWhile($callback)->mapInto(static::class) + ); + } + /** * Sort through each item with a callback. * - * @param callable|null $callback + * @param callable|int|null $callback * @return static */ - public function sort(callable $callback = null) + public function sort($callback = null) { $items = $this->items; - $callback + $callback && is_callable($callback) ? uasort($items, $callback) - : asort($items); + : asort($items, $callback ?? SORT_REGULAR); + + return new static($items); + } + + /** + * Sort items in descending order. + * + * @param int $options + * @return static + */ + public function sortDesc($options = SORT_REGULAR) + { + $items = $this->items; + + arsort($items, $options); return new static($items); } @@ -1066,20 +1281,24 @@ class Collection implements ArrayAccess, Enumerable /** * Sort the collection using the given callback. * - * @param callable|string $callback + * @param callable|array|string $callback * @param int $options * @param bool $descending * @return static */ public function sortBy($callback, $options = SORT_REGULAR, $descending = false) { + if (is_array($callback) && ! is_callable($callback)) { + return $this->sortByMany($callback); + } + $results = []; $callback = $this->valueRetriever($callback); // First we will loop through the items and get the comparator from a callback // function which we were given. Then, we will sort the returned values and - // and grab the corresponding values for the sorted keys from this array. + // grab all the corresponding values for the sorted keys from this array. foreach ($this->items as $key => $value) { $results[$key] = $callback($value, $key); } @@ -1097,6 +1316,50 @@ class Collection implements ArrayAccess, Enumerable return new static($results); } + /** + * Sort the collection using multiple comparisons. + * + * @param array $comparisons + * @return static + */ + protected function sortByMany(array $comparisons = []) + { + $items = $this->items; + + usort($items, function ($a, $b) use ($comparisons) { + foreach ($comparisons as $comparison) { + $comparison = Arr::wrap($comparison); + + $prop = $comparison[0]; + + $ascending = Arr::get($comparison, 1, true) === true || + Arr::get($comparison, 1, true) === 'asc'; + + $result = 0; + + if (! is_string($prop) && is_callable($prop)) { + $result = $prop($a, $b); + } else { + $values = [data_get($a, $prop), data_get($b, $prop)]; + + if (! $ascending) { + $values = array_reverse($values); + } + + $result = $values[0] <=> $values[1]; + } + + if ($result === 0) { + continue; + } + + return $result; + } + }); + + return new static($items); + } + /** * Sort the collection in descending order using the given callback. * @@ -1136,6 +1399,21 @@ class Collection implements ArrayAccess, Enumerable return $this->sortKeys($options, true); } + /** + * Sort the collection keys using a callback. + * + * @param callable $callback + * @return static + */ + public function sortKeysUsing(callable $callback) + { + $items = $this->items; + + uksort($items, $callback); + + return new static($items); + } + /** * Splice a portion of the underlying collection array. * @@ -1150,7 +1428,7 @@ class Collection implements ArrayAccess, Enumerable return new static(array_splice($this->items, $offset)); } - return new static(array_splice($this->items, $offset, $length, $replacement)); + return new static(array_splice($this->items, $offset, $length, $this->getArrayableItems($replacement))); } /** @@ -1168,6 +1446,28 @@ class Collection implements ArrayAccess, Enumerable return $this->slice(0, $limit); } + /** + * Take items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeUntil($value) + { + return new static($this->lazy()->takeUntil($value)->all()); + } + + /** + * Take items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeWhile($value) + { + return new static($this->lazy()->takeWhile($value)->all()); + } + /** * Transform each item in the collection using a callback. * @@ -1181,6 +1481,42 @@ class Collection implements ArrayAccess, Enumerable return $this; } + /** + * Convert a flatten "dot" notation array into an expanded array. + * + * @return static + */ + public function undot() + { + return new static(Arr::undot($this->all())); + } + + /** + * Return only unique items from the collection array. + * + * @param string|callable|null $key + * @param bool $strict + * @return static + */ + public function unique($key = null, $strict = false) + { + if (is_null($key) && $strict === false) { + return new static(array_unique($this->items, SORT_REGULAR)); + } + + $callback = $this->valueRetriever($key); + + $exists = []; + + return $this->reject(function ($item, $key) use ($callback, $strict, &$exists) { + if (in_array($id = $callback($item, $key), $exists, $strict)) { + return true; + } + + $exists[] = $id; + }); + } + /** * Reset the keys on the underlying array. * @@ -1197,7 +1533,7 @@ class Collection implements ArrayAccess, Enumerable * e.g. new Collection([1, 2, 3])->zip([4, 5, 6]); * => [[1, 4], [2, 5], [3, 6]] * - * @param mixed ...$items + * @param mixed ...$items * @return static */ public function zip($items) @@ -1230,6 +1566,7 @@ class Collection implements ArrayAccess, Enumerable * * @return \ArrayIterator */ + #[\ReturnTypeWillChange] public function getIterator() { return new ArrayIterator($this->items); @@ -1240,11 +1577,23 @@ class Collection implements ArrayAccess, Enumerable * * @return int */ + #[\ReturnTypeWillChange] public function count() { return count($this->items); } + /** + * Count the number of items in the collection by a field or using a callback. + * + * @param callable|string $countBy + * @return static + */ + public function countBy($countBy = null) + { + return new static($this->lazy()->countBy($countBy)->all()); + } + /** * Add an item to the collection. * @@ -1274,9 +1623,10 @@ class Collection implements ArrayAccess, Enumerable * @param mixed $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { - return array_key_exists($key, $this->items); + return isset($this->items[$key]); } /** @@ -1285,6 +1635,7 @@ class Collection implements ArrayAccess, Enumerable * @param mixed $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->items[$key]; @@ -1297,6 +1648,7 @@ class Collection implements ArrayAccess, Enumerable * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { if (is_null($key)) { @@ -1309,9 +1661,10 @@ class Collection implements ArrayAccess, Enumerable /** * Unset the item at a given offset. * - * @param string $key + * @param mixed $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->items[$key]); diff --git a/src/Illuminate/Support/Enumerable.php b/src/Illuminate/Collections/Enumerable.php similarity index 87% rename from src/Illuminate/Support/Enumerable.php rename to src/Illuminate/Collections/Enumerable.php index 48991b37f46ffdf2fbed881defc6f934cac7d286..261a0c856b39a5bbfbca68ed3f0a8e1748bacae7 100644 --- a/src/Illuminate/Support/Enumerable.php +++ b/src/Illuminate/Collections/Enumerable.php @@ -22,11 +22,20 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Create a new instance by invoking the callback a given amount of times. * * @param int $number - * @param callable $callback + * @param callable|null $callback * @return static */ public static function times($number, callable $callback = null); + /** + * Create a collection with the given range. + * + * @param int $from + * @param int $to + * @return static + */ + public static function range($from, $to); + /** * Wrap the given value in a collection if applicable. * @@ -43,6 +52,13 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public static function unwrap($value); + /** + * Create a new instance with no items. + * + * @return static + */ + public static function empty(); + /** * Get all items in the enumerable. * @@ -118,6 +134,14 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function contains($key, $operator = null, $value = null); + /** + * Cross join with the given lists, returning all possible permutations. + * + * @param mixed ...$lists + * @return static + */ + public function crossJoin(...$lists); + /** * Dump the collection and end the script. * @@ -187,7 +211,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, /** * Retrieve duplicate items. * - * @param callable|null $callback + * @param callable|string|null $callback * @param bool $strict * @return static */ @@ -196,7 +220,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, /** * Retrieve duplicate items using strict comparison. * - * @param callable|null $callback + * @param callable|string|null $callback * @return static */ public function duplicatesStrict($callback = null); @@ -248,7 +272,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * * @param bool $value * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function when($value, callable $callback, callable $default = null); @@ -257,7 +281,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Apply the callback if the collection is empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function whenEmpty(callable $callback, callable $default = null); @@ -266,7 +290,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Apply the callback if the collection is not empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function whenNotEmpty(callable $callback, callable $default = null); @@ -276,7 +300,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * * @param bool $value * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unless($value, callable $callback, callable $default = null); @@ -285,7 +309,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Apply the callback unless the collection is empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unlessEmpty(callable $callback, callable $default = null); @@ -294,7 +318,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Apply the callback unless the collection is not empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unlessNotEmpty(callable $callback, callable $default = null); @@ -309,6 +333,22 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function where($key, $operator = null, $value = null); + /** + * Filter items where the value for the given key is null. + * + * @param string|null $key + * @return static + */ + public function whereNull($key = null); + + /** + * Filter items where the value for the given key is not null. + * + * @param string|null $key + * @return static + */ + public function whereNotNull($key = null); + /** * Filter items by the given key value pair using strict comparison. * @@ -375,9 +415,9 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, public function whereNotInStrict($key, $values); /** - * Filter the items, removing any items that don't match the given type. + * Filter the items, removing any items that don't match the given type(s). * - * @param string $type + * @param string|string[] $type * @return static */ public function whereInstanceOf($type); @@ -401,6 +441,14 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function firstWhere($key, $operator = null, $value = null); + /** + * Get a flattened array of the items in the collection. + * + * @param int $depth + * @return static + */ + public function flatten($depth = INF); + /** * Flip the values with their keys. * @@ -446,7 +494,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, * Concatenate values of a given key as a string. * * @param string $value - * @param string $glue + * @param string|null $glue * @return string */ public function implode($value, $glue = null); @@ -714,7 +762,7 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, /** * Shuffle the items in the collection. * - * @param int $seed + * @param int|null $seed * @return static */ public function shuffle($seed = null); @@ -727,11 +775,27 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function skip($count); + /** + * Skip items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipUntil($value); + + /** + * Skip items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipWhile($value); + /** * Get a slice of items from the enumerable. * * @param int $offset - * @param int $length + * @param int|null $length * @return static */ public function slice($offset, $length = null); @@ -752,13 +816,29 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function chunk($size); + /** + * Chunk the collection into chunks with a callback. + * + * @param callable $callback + * @return static + */ + public function chunkWhile(callable $callback); + /** * Sort through each item with a callback. * - * @param callable|null $callback + * @param callable|null|int $callback * @return static */ - public function sort(callable $callback = null); + public function sort($callback = null); + + /** + * Sort items in descending order. + * + * @param int $options + * @return static + */ + public function sortDesc($options = SORT_REGULAR); /** * Sort the collection using the given callback. @@ -812,6 +892,22 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function take($limit); + /** + * Take items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeUntil($value); + + /** + * Take items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeWhile($value); + /** * Pass the collection to the given callback and then return it. * @@ -886,6 +982,17 @@ interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, */ public function countBy($callback = null); + /** + * Zip the collection together with one or more arrays. + * + * e.g. new Collection([1, 2, 3])->zip([4, 5, 6]); + * => [[1, 4], [2, 5], [3, 6]] + * + * @param mixed ...$items + * @return static + */ + public function zip($items); + /** * Collect the values into a collection. * diff --git a/src/Illuminate/Support/HigherOrderCollectionProxy.php b/src/Illuminate/Collections/HigherOrderCollectionProxy.php similarity index 100% rename from src/Illuminate/Support/HigherOrderCollectionProxy.php rename to src/Illuminate/Collections/HigherOrderCollectionProxy.php diff --git a/src/Illuminate/Collections/HigherOrderWhenProxy.php b/src/Illuminate/Collections/HigherOrderWhenProxy.php new file mode 100644 index 0000000000000000000000000000000000000000..6653c03a656fd4462a485f140ea3d04678b3f05b --- /dev/null +++ b/src/Illuminate/Collections/HigherOrderWhenProxy.php @@ -0,0 +1,63 @@ +<?php + +namespace Illuminate\Support; + +/** + * @mixin \Illuminate\Support\Enumerable + */ +class HigherOrderWhenProxy +{ + /** + * The collection being operated on. + * + * @var \Illuminate\Support\Enumerable + */ + protected $collection; + + /** + * The condition for proxying. + * + * @var bool + */ + protected $condition; + + /** + * Create a new proxy instance. + * + * @param \Illuminate\Support\Enumerable $collection + * @param bool $condition + * @return void + */ + public function __construct(Enumerable $collection, $condition) + { + $this->condition = $condition; + $this->collection = $collection; + } + + /** + * Proxy accessing an attribute onto the collection. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + return $this->condition + ? $this->collection->{$key} + : $this->collection; + } + + /** + * Proxy a method call onto the collection. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->condition + ? $this->collection->{$method}(...$parameters) + : $this->collection; + } +} diff --git a/src/Illuminate/Collections/ItemNotFoundException.php b/src/Illuminate/Collections/ItemNotFoundException.php new file mode 100644 index 0000000000000000000000000000000000000000..05a51d95475e749baedcbe40943a4acb22f7b887 --- /dev/null +++ b/src/Illuminate/Collections/ItemNotFoundException.php @@ -0,0 +1,9 @@ +<?php + +namespace Illuminate\Support; + +use RuntimeException; + +class ItemNotFoundException extends RuntimeException +{ +} diff --git a/src/Illuminate/Collections/LICENSE.md b/src/Illuminate/Collections/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..79810c848f8bdbd8f1629f46079ad482f33fc371 --- /dev/null +++ b/src/Illuminate/Collections/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Illuminate/Support/LazyCollection.php b/src/Illuminate/Collections/LazyCollection.php similarity index 74% rename from src/Illuminate/Support/LazyCollection.php rename to src/Illuminate/Collections/LazyCollection.php index 5956ba7841491eb94a8609f7bfe282458cd93554..e1cdcd99d6eb23414c96ceca5bd5c73c0c56ff99 100644 --- a/src/Illuminate/Support/LazyCollection.php +++ b/src/Illuminate/Collections/LazyCollection.php @@ -4,12 +4,14 @@ namespace Illuminate\Support; use ArrayIterator; use Closure; +use DateTimeInterface; +use Illuminate\Contracts\Support\CanBeEscapedWhenCastToString; use Illuminate\Support\Traits\EnumeratesValues; use Illuminate\Support\Traits\Macroable; use IteratorAggregate; use stdClass; -class LazyCollection implements Enumerable +class LazyCollection implements CanBeEscapedWhenCastToString, Enumerable { use EnumeratesValues, Macroable; @@ -38,39 +40,7 @@ class LazyCollection implements Enumerable } /** - * Create a new instance with no items. - * - * @return static - */ - public static function empty() - { - return new static([]); - } - - /** - * Create a new instance by invoking the callback a given amount of times. - * - * @param int $number - * @param callable $callback - * @return static - */ - public static function times($number, callable $callback = null) - { - if ($number < 1) { - return new static; - } - - $instance = new static(function () use ($number) { - for ($current = 1; $current <= $number; $current++) { - yield $current; - } - }); - - return is_null($callback) ? $instance : $instance->map($callback); - } - - /** - * Create an enumerable with the given range. + * Create a collection with the given range. * * @param int $from * @param int $to @@ -79,8 +49,14 @@ class LazyCollection implements Enumerable public static function range($from, $to) { return new static(function () use ($from, $to) { - for (; $from <= $to; $from++) { - yield $from; + if ($from <= $to) { + for (; $from <= $to; $from++) { + yield $from; + } + } else { + for (; $from >= $to; $from--) { + yield $from; + } } }); } @@ -229,6 +205,19 @@ class LazyCollection implements Enumerable return $this->contains($this->operatorForWhere(...func_get_args())); } + /** + * Determine if an item is not contained in the enumerable. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return bool + */ + public function doesntContain($key, $operator = null, $value = null) + { + return ! $this->contains(...func_get_args()); + } + /** * Cross join the given iterables, returning all possible permutations. * @@ -240,6 +229,35 @@ class LazyCollection implements Enumerable return $this->passthru('crossJoin', func_get_args()); } + /** + * Count the number of items in the collection by a field or using a callback. + * + * @param callable|string $countBy + * @return static + */ + public function countBy($countBy = null) + { + $countBy = is_null($countBy) + ? $this->identity() + : $this->valueRetriever($countBy); + + return new static(function () use ($countBy) { + $counts = []; + + foreach ($this as $key => $value) { + $group = $countBy($value, $key); + + if (empty($counts[$group])) { + $counts[$group] = 0; + } + + $counts[$group]++; + } + + yield from $counts; + }); + } + /** * Get the items that are not present in the given items. * @@ -312,7 +330,7 @@ class LazyCollection implements Enumerable /** * Retrieve duplicate items. * - * @param callable|null $callback + * @param callable|string|null $callback * @param bool $strict * @return static */ @@ -324,7 +342,7 @@ class LazyCollection implements Enumerable /** * Retrieve duplicate items using strict comparison. * - * @param callable|null $callback + * @param callable|string|null $callback * @return static */ public function duplicatesStrict($callback = null) @@ -508,11 +526,30 @@ class LazyCollection implements Enumerable return false; } + /** + * Determine if any of the keys exist in the collection. + * + * @param mixed $key + * @return bool + */ + public function hasAny($key) + { + $keys = array_flip(is_array($key) ? $key : func_get_args()); + + foreach ($this as $key => $value) { + if (array_key_exists($key, $keys)) { + return true; + } + } + + return false; + } + /** * Concatenate values of a given key as a string. * * @param string $value - * @param string $glue + * @param string|null $glue * @return string */ public function implode($value, $glue = null) @@ -543,7 +580,7 @@ class LazyCollection implements Enumerable } /** - * Determine if the items is empty or not. + * Determine if the items are empty or not. * * @return bool */ @@ -552,6 +589,16 @@ class LazyCollection implements Enumerable return ! $this->getIterator()->valid(); } + /** + * Determine if the collection contains a single item. + * + * @return bool + */ + public function containsOneItem() + { + return $this->take(2)->count() === 1; + } + /** * Join all items from the collection using a string. The final items can use a separate glue string. * @@ -749,8 +796,8 @@ class LazyCollection implements Enumerable return new static(function () use ($step, $offset) { $position = 0; - foreach ($this as $item) { - if ($position % $step === $offset) { + foreach ($this->slice($offset) as $item) { + if ($position % $step === 0) { yield $item; } @@ -823,24 +870,6 @@ class LazyCollection implements Enumerable return is_null($number) ? $result : new static($result); } - /** - * Reduce the collection to a single value. - * - * @param callable $callback - * @param mixed $initial - * @return mixed - */ - public function reduce(callable $callback, $initial = null) - { - $result = $initial; - - foreach ($this as $value) { - $result = $callback($result, $value); - } - - return $result; - } - /** * Replace the collection items with the given items. * @@ -916,7 +945,7 @@ class LazyCollection implements Enumerable /** * Shuffle the items in the collection. * - * @param int $seed + * @param int|null $seed * @return static */ public function shuffle($seed = null) @@ -924,6 +953,45 @@ class LazyCollection implements Enumerable return $this->passthru('shuffle', func_get_args()); } + /** + * Create chunks representing a "sliding window" view of the items in the collection. + * + * @param int $size + * @param int $step + * @return static + */ + public function sliding($size = 2, $step = 1) + { + return new static(function () use ($size, $step) { + $iterator = $this->getIterator(); + + $chunk = []; + + while ($iterator->valid()) { + $chunk[$iterator->key()] = $iterator->current(); + + if (count($chunk) == $size) { + yield tap(new static($chunk), function () use (&$chunk, $step) { + $chunk = array_slice($chunk, $step, null, true); + }); + + // If the $step between chunks is bigger than each chunk's $size + // we will skip the extra items (which should never be in any + // chunk) before we continue to the next chunk in the loop. + if ($step > $size) { + $skip = $step - $size; + + for ($i = 0; $i < $skip && $iterator->valid(); $i++) { + $iterator->next(); + } + } + } + + $iterator->next(); + } + }); + } + /** * Skip the first {$count} items. * @@ -947,11 +1015,49 @@ class LazyCollection implements Enumerable }); } + /** + * Skip items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipUntil($value) + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return $this->skipWhile($this->negate($callback)); + } + + /** + * Skip items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function skipWhile($value) + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return new static(function () use ($callback) { + $iterator = $this->getIterator(); + + while ($iterator->valid() && $callback($iterator->current(), $iterator->key())) { + $iterator->next(); + } + + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + + $iterator->next(); + } + }); + } + /** * Get a slice of items from the enumerable. * * @param int $offset - * @param int $length + * @param int|null $length * @return static */ public function slice($offset, $length = null) @@ -976,6 +1082,55 @@ class LazyCollection implements Enumerable return $this->passthru('split', func_get_args()); } + /** + * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return mixed + * + * @throws \Illuminate\Support\ItemNotFoundException + * @throws \Illuminate\Support\MultipleItemsFoundException + */ + public function sole($key = null, $operator = null, $value = null) + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->when($filter) + ->filter($filter) + ->take(2) + ->collect() + ->sole(); + } + + /** + * Get the first item in the collection but throw an exception if no matching items exist. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @return mixed + * + * @throws \Illuminate\Support\ItemNotFoundException + */ + public function firstOrFail($key = null, $operator = null, $value = null) + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->when($filter) + ->filter($filter) + ->take(1) + ->collect() + ->firstOrFail(); + } + /** * Chunk the collection into chunks of the given size. * @@ -1015,17 +1170,76 @@ class LazyCollection implements Enumerable }); } + /** + * Split a collection into a certain number of groups, and fill the first groups completely. + * + * @param int $numberOfGroups + * @return static + */ + public function splitIn($numberOfGroups) + { + return $this->chunk(ceil($this->count() / $numberOfGroups)); + } + + /** + * Chunk the collection into chunks with a callback. + * + * @param callable $callback + * @return static + */ + public function chunkWhile(callable $callback) + { + return new static(function () use ($callback) { + $iterator = $this->getIterator(); + + $chunk = new Collection; + + if ($iterator->valid()) { + $chunk[$iterator->key()] = $iterator->current(); + + $iterator->next(); + } + + while ($iterator->valid()) { + if (! $callback($iterator->current(), $iterator->key(), $chunk)) { + yield new static($chunk); + + $chunk = new Collection; + } + + $chunk[$iterator->key()] = $iterator->current(); + + $iterator->next(); + } + + if ($chunk->isNotEmpty()) { + yield new static($chunk); + } + }); + } + /** * Sort through each item with a callback. * - * @param callable|null $callback + * @param callable|null|int $callback * @return static */ - public function sort(callable $callback = null) + public function sort($callback = null) { return $this->passthru('sort', func_get_args()); } + /** + * Sort items in descending order. + * + * @param int $options + * @return static + */ + public function sortDesc($options = SORT_REGULAR) + { + return $this->passthru('sortDesc', func_get_args()); + } + /** * Sort the collection using the given callback. * @@ -1074,6 +1288,17 @@ class LazyCollection implements Enumerable return $this->passthru('sortKeysDesc', func_get_args()); } + /** + * Sort the collection keys using a callback. + * + * @param callable $callback + * @return static + */ + public function sortKeysUsing(callable $callback) + { + return $this->passthru('sortKeysUsing', func_get_args()); + } + /** * Take the first or last {$limit} items. * @@ -1103,6 +1328,57 @@ class LazyCollection implements Enumerable }); } + /** + * Take items in the collection until the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeUntil($value) + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return new static(function () use ($callback) { + foreach ($this as $key => $item) { + if ($callback($item, $key)) { + break; + } + + yield $key => $item; + } + }); + } + + /** + * Take items in the collection until a given point in time. + * + * @param \DateTimeInterface $timeout + * @return static + */ + public function takeUntilTimeout(DateTimeInterface $timeout) + { + $timeout = $timeout->getTimestamp(); + + return $this->takeWhile(function () use ($timeout) { + return $this->now() < $timeout; + }); + } + + /** + * Take items in the collection while the given condition is met. + * + * @param mixed $value + * @return static + */ + public function takeWhile($value) + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return $this->takeUntil(function ($item, $key) use ($callback) { + return ! $callback($item, $key); + }); + } + /** * Pass each item in the collection to the given callback, lazily. * @@ -1120,6 +1396,40 @@ class LazyCollection implements Enumerable }); } + /** + * Convert a flatten "dot" notation array into an expanded array. + * + * @return static + */ + public function undot() + { + return $this->passthru('undot', []); + } + + /** + * Return only unique items from the collection array. + * + * @param string|callable|null $key + * @param bool $strict + * @return static + */ + public function unique($key = null, $strict = false) + { + $callback = $this->valueRetriever($key); + + return new static(function () use ($callback, $strict) { + $exists = []; + + foreach ($this as $key => $item) { + if (! in_array($id = $callback($item, $key), $exists, $strict)) { + yield $key => $item; + + $exists[] = $id; + } + } + }); + } + /** * Reset the keys on the underlying array. * @@ -1140,7 +1450,7 @@ class LazyCollection implements Enumerable * e.g. new LazyCollection([1, 2, 3])->zip([4, 5, 6]); * => [[1, 4], [2, 5], [3, 6]] * - * @param mixed ...$items + * @param mixed ...$items * @return static */ public function zip($items) @@ -1193,6 +1503,7 @@ class LazyCollection implements Enumerable * * @return \Traversable */ + #[\ReturnTypeWillChange] public function getIterator() { return $this->makeIterator($this->source); @@ -1203,6 +1514,7 @@ class LazyCollection implements Enumerable * * @return int */ + #[\ReturnTypeWillChange] public function count() { if (is_array($this->source)) { @@ -1260,4 +1572,14 @@ class LazyCollection implements Enumerable yield from $this->collect()->$method(...$params); }); } + + /** + * Get the current time. + * + * @return int + */ + protected function now() + { + return time(); + } } diff --git a/src/Illuminate/Collections/MultipleItemsFoundException.php b/src/Illuminate/Collections/MultipleItemsFoundException.php new file mode 100644 index 0000000000000000000000000000000000000000..944b2dc6413d417dd75767550dd681a67bb7a0e9 --- /dev/null +++ b/src/Illuminate/Collections/MultipleItemsFoundException.php @@ -0,0 +1,9 @@ +<?php + +namespace Illuminate\Support; + +use RuntimeException; + +class MultipleItemsFoundException extends RuntimeException +{ +} diff --git a/src/Illuminate/Support/Traits/EnumeratesValues.php b/src/Illuminate/Collections/Traits/EnumeratesValues.php similarity index 76% rename from src/Illuminate/Support/Traits/EnumeratesValues.php rename to src/Illuminate/Collections/Traits/EnumeratesValues.php index f7e9bfc60a6376b487372456e913ed3bcf01005b..269d1a66656f1488d389aa4ffe827a568184613a 100644 --- a/src/Illuminate/Support/Traits/EnumeratesValues.php +++ b/src/Illuminate/Collections/Traits/EnumeratesValues.php @@ -3,6 +3,7 @@ namespace Illuminate\Support\Traits; use CachingIterator; +use Closure; use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; @@ -10,14 +11,18 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Enumerable; use Illuminate\Support\HigherOrderCollectionProxy; +use Illuminate\Support\HigherOrderWhenProxy; use JsonSerializable; use Symfony\Component\VarDumper\VarDumper; use Traversable; +use UnexpectedValueException; +use UnitEnum; /** * @property-read HigherOrderCollectionProxy $average * @property-read HigherOrderCollectionProxy $avg * @property-read HigherOrderCollectionProxy $contains + * @property-read HigherOrderCollectionProxy $doesntContain * @property-read HigherOrderCollectionProxy $each * @property-read HigherOrderCollectionProxy $every * @property-read HigherOrderCollectionProxy $filter @@ -30,22 +35,58 @@ use Traversable; * @property-read HigherOrderCollectionProxy $min * @property-read HigherOrderCollectionProxy $partition * @property-read HigherOrderCollectionProxy $reject + * @property-read HigherOrderCollectionProxy $some * @property-read HigherOrderCollectionProxy $sortBy * @property-read HigherOrderCollectionProxy $sortByDesc + * @property-read HigherOrderCollectionProxy $skipUntil + * @property-read HigherOrderCollectionProxy $skipWhile * @property-read HigherOrderCollectionProxy $sum + * @property-read HigherOrderCollectionProxy $takeUntil + * @property-read HigherOrderCollectionProxy $takeWhile * @property-read HigherOrderCollectionProxy $unique + * @property-read HigherOrderCollectionProxy $until */ trait EnumeratesValues { + /** + * Indicates that the object's string representation should be escaped when __toString is invoked. + * + * @var bool + */ + protected $escapeWhenCastingToString = false; + /** * The methods that can be proxied. * - * @var array + * @var string[] */ protected static $proxies = [ - 'average', 'avg', 'contains', 'each', 'every', 'filter', 'first', - 'flatMap', 'groupBy', 'keyBy', 'map', 'max', 'min', 'partition', - 'reject', 'some', 'sortBy', 'sortByDesc', 'sum', 'unique', + 'average', + 'avg', + 'contains', + 'doesntContain', + 'each', + 'every', + 'filter', + 'first', + 'flatMap', + 'groupBy', + 'keyBy', + 'map', + 'max', + 'min', + 'partition', + 'reject', + 'skipUntil', + 'skipWhile', + 'some', + 'sortBy', + 'sortByDesc', + 'sum', + 'takeUntil', + 'takeWhile', + 'unique', + 'until', ]; /** @@ -83,6 +124,34 @@ trait EnumeratesValues return $value instanceof Enumerable ? $value->all() : $value; } + /** + * Create a new instance with no items. + * + * @return static + */ + public static function empty() + { + return new static([]); + } + + /** + * Create a new collection by invoking the callback a given amount of times. + * + * @param int $number + * @param callable|null $callback + * @return static + */ + public static function times($number, callable $callback = null) + { + if ($number < 1) { + return new static; + } + + return static::range(1, $number) + ->when($callback) + ->map($callback); + } + /** * Alias for the "avg" method. * @@ -155,8 +224,8 @@ trait EnumeratesValues */ public function dump() { - (new static(func_get_args())) - ->push($this) + (new Collection(func_get_args())) + ->push($this->all()) ->each(function ($item) { VarDumper::dump($item); }); @@ -386,13 +455,9 @@ trait EnumeratesValues */ public function sum($callback = null) { - if (is_null($callback)) { - $callback = function ($value) { - return $value; - }; - } else { - $callback = $this->valueRetriever($callback); - } + $callback = is_null($callback) + ? $this->identity() + : $this->valueRetriever($callback); return $this->reduce(function ($result, $item) use ($callback) { return $result + $callback($item); @@ -403,12 +468,16 @@ trait EnumeratesValues * Apply the callback if the value is truthy. * * @param bool|mixed $value - * @param callable $callback - * @param callable $default + * @param callable|null $callback + * @param callable|null $default * @return static|mixed */ - public function when($value, callable $callback, callable $default = null) + public function when($value, callable $callback = null, callable $default = null) { + if (! $callback) { + return new HigherOrderWhenProxy($this, $value); + } + if ($value) { return $callback($this, $value); } elseif ($default) { @@ -422,7 +491,7 @@ trait EnumeratesValues * Apply the callback if the collection is empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function whenEmpty(callable $callback, callable $default = null) @@ -434,7 +503,7 @@ trait EnumeratesValues * Apply the callback if the collection is not empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function whenNotEmpty(callable $callback, callable $default = null) @@ -447,7 +516,7 @@ trait EnumeratesValues * * @param bool $value * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unless($value, callable $callback, callable $default = null) @@ -459,7 +528,7 @@ trait EnumeratesValues * Apply the callback unless the collection is empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unlessEmpty(callable $callback, callable $default = null) @@ -471,7 +540,7 @@ trait EnumeratesValues * Apply the callback unless the collection is not empty. * * @param callable $callback - * @param callable $default + * @param callable|null $default * @return static|mixed */ public function unlessNotEmpty(callable $callback, callable $default = null) @@ -493,7 +562,7 @@ trait EnumeratesValues } /** - * Filter items where the given key is not null. + * Filter items where the value for the given key is null. * * @param string|null $key * @return static @@ -504,7 +573,7 @@ trait EnumeratesValues } /** - * Filter items where the given key is null. + * Filter items where the value for the given key is not null. * * @param string|null $key * @return static @@ -611,14 +680,24 @@ trait EnumeratesValues } /** - * Filter the items, removing any items that don't match the given type. + * Filter the items, removing any items that don't match the given type(s). * - * @param string $type + * @param string|string[] $type * @return static */ public function whereInstanceOf($type) { return $this->filter(function ($value) use ($type) { + if (is_array($type)) { + foreach ($type as $classType) { + if ($value instanceof $classType) { + return true; + } + } + + return false; + } + return $value instanceof $type; }); } @@ -634,6 +713,33 @@ trait EnumeratesValues return $callback($this); } + /** + * Pass the collection into a new class. + * + * @param string $class + * @return mixed + */ + public function pipeInto($class) + { + return new $class($this); + } + + /** + * Pass the collection through a series of callable pipes and return the result. + * + * @param array<callable> $pipes + * @return mixed + */ + public function pipeThrough($pipes) + { + return static::make($pipes)->reduce( + function ($carry, $pipe) { + return $pipe($carry); + }, + $this, + ); + } + /** * Pass the collection to the given callback and then return it. * @@ -648,41 +754,92 @@ trait EnumeratesValues } /** - * Create a collection of all elements that do not pass a given truth test. + * Reduce the collection to a single value. * - * @param callable|mixed $callback - * @return static + * @param callable $callback + * @param mixed $initial + * @return mixed */ - public function reject($callback = true) + public function reduce(callable $callback, $initial = null) { - $useAsCallable = $this->useAsCallable($callback); + $result = $initial; - return $this->filter(function ($value, $key) use ($callback, $useAsCallable) { - return $useAsCallable - ? ! $callback($value, $key) - : $value != $callback; - }); + foreach ($this as $key => $value) { + $result = $callback($result, $value, $key); + } + + return $result; } /** - * Return only unique items from the collection array. + * Reduce the collection to multiple aggregate values. * - * @param string|callable|null $key - * @param bool $strict - * @return static + * @param callable $callback + * @param mixed ...$initial + * @return array + * + * @deprecated Use "reduceSpread" instead + * + * @throws \UnexpectedValueException */ - public function unique($key = null, $strict = false) + public function reduceMany(callable $callback, ...$initial) { - $callback = $this->valueRetriever($key); + return $this->reduceSpread($callback, ...$initial); + } - $exists = []; + /** + * Reduce the collection to multiple aggregate values. + * + * @param callable $callback + * @param mixed ...$initial + * @return array + * + * @throws \UnexpectedValueException + */ + public function reduceSpread(callable $callback, ...$initial) + { + $result = $initial; - return $this->reject(function ($item, $key) use ($callback, $strict, &$exists) { - if (in_array($id = $callback($item, $key), $exists, $strict)) { - return true; + foreach ($this as $key => $value) { + $result = call_user_func_array($callback, array_merge($result, [$value, $key])); + + if (! is_array($result)) { + throw new UnexpectedValueException(sprintf( + "%s::reduceMany expects reducer to return an array, but got a '%s' instead.", + class_basename(static::class), gettype($result) + )); } + } + + return $result; + } + + /** + * Reduce an associative collection to a single value. + * + * @param callable $callback + * @param mixed $initial + * @return mixed + */ + public function reduceWithKeys(callable $callback, $initial = null) + { + return $this->reduce($callback, $initial); + } - $exists[] = $id; + /** + * Create a collection of all elements that do not pass a given truth test. + * + * @param callable|mixed $callback + * @return static + */ + public function reject($callback = true) + { + $useAsCallable = $this->useAsCallable($callback); + + return $this->filter(function ($value, $key) use ($callback, $useAsCallable) { + return $useAsCallable + ? ! $callback($value, $key) + : $value != $callback; }); } @@ -724,6 +881,7 @@ trait EnumeratesValues * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return array_map(function ($value) { @@ -762,32 +920,28 @@ trait EnumeratesValues } /** - * Count the number of items in the collection using a given truth test. + * Convert the collection to its string representation. * - * @param callable|null $callback - * @return static + * @return string */ - public function countBy($callback = null) + public function __toString() { - if (is_null($callback)) { - $callback = function ($value) { - return $value; - }; - } - - return new static($this->groupBy($callback)->map(function ($value) { - return $value->count(); - })); + return $this->escapeWhenCastingToString + ? e($this->toJson()) + : $this->toJson(); } /** - * Convert the collection to its string representation. + * Indicate that the model's string representation should be escaped when __toString is invoked. * - * @return string + * @param bool $escape + * @return $this */ - public function __toString() + public function escapeWhenCastingToString($escape = true) { - return $this->toJson(); + $this->escapeWhenCastingToString = $escape; + + return $this; } /** @@ -838,6 +992,8 @@ trait EnumeratesValues return (array) $items->jsonSerialize(); } elseif ($items instanceof Traversable) { return iterator_to_array($items); + } elseif ($items instanceof UnitEnum) { + return [$items]; } return (array) $items; @@ -919,4 +1075,42 @@ trait EnumeratesValues return data_get($item, $value); }; } + + /** + * Make a function to check an item's equality. + * + * @param mixed $value + * @return \Closure + */ + protected function equality($value) + { + return function ($item) use ($value) { + return $item === $value; + }; + } + + /** + * Make a function using another function, by negating its result. + * + * @param \Closure $callback + * @return \Closure + */ + protected function negate(Closure $callback) + { + return function (...$params) use ($callback) { + return ! $callback(...$params); + }; + } + + /** + * Make a function that returns what's passed to it. + * + * @return \Closure + */ + protected function identity() + { + return function ($value) { + return $value; + }; + } } diff --git a/src/Illuminate/Collections/composer.json b/src/Illuminate/Collections/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..ecc453726639072eaa1ddadfea3cba0ac0b26a75 --- /dev/null +++ b/src/Illuminate/Collections/composer.json @@ -0,0 +1,41 @@ +{ + "name": "illuminate/collections", + "description": "The Illuminate Collections package.", + "license": "MIT", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "require": { + "php": "^7.3|^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0" + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + }, + "files": [ + "helpers.php" + ] + }, + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "suggest": { + "symfony/var-dumper": "Required to use the dump method (^5.4)." + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev" +} diff --git a/src/Illuminate/Collections/helpers.php b/src/Illuminate/Collections/helpers.php new file mode 100644 index 0000000000000000000000000000000000000000..67669e5ce1c68aad112fbb0c83936a25d1b6fe75 --- /dev/null +++ b/src/Illuminate/Collections/helpers.php @@ -0,0 +1,186 @@ +<?php + +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; + +if (! function_exists('collect')) { + /** + * Create a collection from the given value. + * + * @param mixed $value + * @return \Illuminate\Support\Collection + */ + function collect($value = null) + { + return new Collection($value); + } +} + +if (! function_exists('data_fill')) { + /** + * Fill in data where it's missing. + * + * @param mixed $target + * @param string|array $key + * @param mixed $value + * @return mixed + */ + function data_fill(&$target, $key, $value) + { + return data_set($target, $key, $value, false); + } +} + +if (! function_exists('data_get')) { + /** + * Get an item from an array or object using "dot" notation. + * + * @param mixed $target + * @param string|array|int|null $key + * @param mixed $default + * @return mixed + */ + function data_get($target, $key, $default = null) + { + if (is_null($key)) { + return $target; + } + + $key = is_array($key) ? $key : explode('.', $key); + + foreach ($key as $i => $segment) { + unset($key[$i]); + + if (is_null($segment)) { + return $target; + } + + if ($segment === '*') { + if ($target instanceof Collection) { + $target = $target->all(); + } elseif (! is_array($target)) { + return value($default); + } + + $result = []; + + foreach ($target as $item) { + $result[] = data_get($item, $key); + } + + return in_array('*', $key) ? Arr::collapse($result) : $result; + } + + if (Arr::accessible($target) && Arr::exists($target, $segment)) { + $target = $target[$segment]; + } elseif (is_object($target) && isset($target->{$segment})) { + $target = $target->{$segment}; + } else { + return value($default); + } + } + + return $target; + } +} + +if (! function_exists('data_set')) { + /** + * Set an item on an array or object using dot notation. + * + * @param mixed $target + * @param string|array $key + * @param mixed $value + * @param bool $overwrite + * @return mixed + */ + function data_set(&$target, $key, $value, $overwrite = true) + { + $segments = is_array($key) ? $key : explode('.', $key); + + if (($segment = array_shift($segments)) === '*') { + if (! Arr::accessible($target)) { + $target = []; + } + + if ($segments) { + foreach ($target as &$inner) { + data_set($inner, $segments, $value, $overwrite); + } + } elseif ($overwrite) { + foreach ($target as &$inner) { + $inner = $value; + } + } + } elseif (Arr::accessible($target)) { + if ($segments) { + if (! Arr::exists($target, $segment)) { + $target[$segment] = []; + } + + data_set($target[$segment], $segments, $value, $overwrite); + } elseif ($overwrite || ! Arr::exists($target, $segment)) { + $target[$segment] = $value; + } + } elseif (is_object($target)) { + if ($segments) { + if (! isset($target->{$segment})) { + $target->{$segment} = []; + } + + data_set($target->{$segment}, $segments, $value, $overwrite); + } elseif ($overwrite || ! isset($target->{$segment})) { + $target->{$segment} = $value; + } + } else { + $target = []; + + if ($segments) { + data_set($target[$segment], $segments, $value, $overwrite); + } elseif ($overwrite) { + $target[$segment] = $value; + } + } + + return $target; + } +} + +if (! function_exists('head')) { + /** + * Get the first element of an array. Useful for method chaining. + * + * @param array $array + * @return mixed + */ + function head($array) + { + return reset($array); + } +} + +if (! function_exists('last')) { + /** + * Get the last element from an array. + * + * @param array $array + * @return mixed + */ + function last($array) + { + return end($array); + } +} + +if (! function_exists('value')) { + /** + * Return the default value of the given value. + * + * @param mixed $value + * @return mixed + */ + function value($value, ...$args) + { + return $value instanceof Closure ? $value(...$args) : $value; + } +} diff --git a/src/Illuminate/Config/Repository.php b/src/Illuminate/Config/Repository.php index 5bcdcbf486c6beb31a57b0ea3d897658941c5c76..1719e90a90c2c002b0860bd5c9d76e9778a95710 100644 --- a/src/Illuminate/Config/Repository.php +++ b/src/Illuminate/Config/Repository.php @@ -99,7 +99,7 @@ class Repository implements ArrayAccess, ConfigContract */ public function prepend($key, $value) { - $array = $this->get($key); + $array = $this->get($key, []); array_unshift($array, $value); @@ -115,7 +115,7 @@ class Repository implements ArrayAccess, ConfigContract */ public function push($key, $value) { - $array = $this->get($key); + $array = $this->get($key, []); $array[] = $value; @@ -138,6 +138,7 @@ class Repository implements ArrayAccess, ConfigContract * @param string $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return $this->has($key); @@ -149,6 +150,7 @@ class Repository implements ArrayAccess, ConfigContract * @param string $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->get($key); @@ -161,6 +163,7 @@ class Repository implements ArrayAccess, ConfigContract * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->set($key, $value); @@ -172,6 +175,7 @@ class Repository implements ArrayAccess, ConfigContract * @param string $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { $this->set($key, null); diff --git a/src/Illuminate/Config/composer.json b/src/Illuminate/Config/composer.json index 3704ba14fd7ea218633303d6ee6d1254db7ed06d..9d577bb46fae61db5d9148f5f077e34924ef1883 100755 --- a/src/Illuminate/Config/composer.json +++ b/src/Illuminate/Config/composer.json @@ -14,9 +14,9 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0" }, "autoload": { "psr-4": { @@ -25,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 7066c8485425f969d64a1fd810663539ac14a891..88c65c70817109da091c98f15de056978720e247 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -19,7 +19,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\PhpExecutableFinder; @@ -77,6 +76,8 @@ class Application extends SymfonyApplication implements ApplicationContract /** * {@inheritdoc} + * + * @return int */ public function run(InputInterface $input = null, OutputInterface $output = null) { @@ -86,7 +87,7 @@ class Application extends SymfonyApplication implements ApplicationContract $this->events->dispatch( new CommandStarting( - $commandName, $input, $output = $output ?: new ConsoleOutput + $commandName, $input, $output = $output ?: new BufferedConsoleOutput ) ); @@ -116,7 +117,7 @@ class Application extends SymfonyApplication implements ApplicationContract */ public static function artisanBinary() { - return defined('ARTISAN_BINARY') ? ProcessUtils::escapeArgument(ARTISAN_BINARY) : 'artisan'; + return ProcessUtils::escapeArgument(defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan'); } /** @@ -209,7 +210,7 @@ class Application extends SymfonyApplication implements ApplicationContract $input = new ArrayInput($parameters); } - return [$command, $input ?? null]; + return [$command, $input]; } /** diff --git a/src/Illuminate/Console/BufferedConsoleOutput.php b/src/Illuminate/Console/BufferedConsoleOutput.php new file mode 100644 index 0000000000000000000000000000000000000000..4bb5ca22854151ed34891a9ccbc730f454183227 --- /dev/null +++ b/src/Illuminate/Console/BufferedConsoleOutput.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Console; + +use Symfony\Component\Console\Output\ConsoleOutput; + +class BufferedConsoleOutput extends ConsoleOutput +{ + /** + * The current buffer. + * + * @var string + */ + protected $buffer = ''; + + /** + * Empties the buffer and returns its content. + * + * @return string + */ + public function fetch() + { + return tap($this->buffer, function () { + $this->buffer = ''; + }); + } + + /** + * {@inheritdoc} + */ + protected function doWrite(string $message, bool $newline) + { + $this->buffer .= $message; + + if ($newline) { + $this->buffer .= \PHP_EOL; + } + + return parent::doWrite($message, $newline); + } +} diff --git a/src/Illuminate/Console/Command.php b/src/Illuminate/Console/Command.php index 8312a4781371a3ff10f9153c29412fdd65bbfd85..5c8c179f03cf1f9c973be84e454cd60e84cf364a 100755 --- a/src/Illuminate/Console/Command.php +++ b/src/Illuminate/Console/Command.php @@ -38,14 +38,14 @@ class Command extends SymfonyCommand /** * The console command description. * - * @var string|null + * @var string */ protected $description; /** * The console command help text. * - * @var string|null + * @var string */ protected $help; @@ -127,11 +127,13 @@ class Command extends SymfonyCommand * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return mixed + * @return int */ protected function execute(InputInterface $input, OutputInterface $output) { - return $this->laravel->call([$this, 'handle']); + $method = method_exists($this, 'handle') ? 'handle' : '__invoke'; + + return (int) $this->laravel->call([$this, $method]); } /** @@ -161,6 +163,8 @@ class Command extends SymfonyCommand /** * {@inheritdoc} + * + * @return bool */ public function isHidden() { @@ -169,8 +173,10 @@ class Command extends SymfonyCommand /** * {@inheritdoc} + * + * @return static */ - public function setHidden($hidden) + public function setHidden(bool $hidden) { parent::setHidden($this->hidden = $hidden); diff --git a/src/Illuminate/Console/Concerns/CallsCommands.php b/src/Illuminate/Console/Concerns/CallsCommands.php index e060c55626067c4ce5c17c605bc87ce93429fbf8..7e69b9b7891d0fbf0cbe3c3feeda8878ec07e925 100644 --- a/src/Illuminate/Console/Concerns/CallsCommands.php +++ b/src/Illuminate/Console/Concerns/CallsCommands.php @@ -29,7 +29,7 @@ trait CallsCommands } /** - * Call another console command silently. + * Call another console command without output. * * @param \Symfony\Component\Console\Command\Command|string $command * @param array $arguments @@ -40,6 +40,18 @@ trait CallsCommands return $this->runCommand($command, $arguments, new NullOutput); } + /** + * Call another console command without output. + * + * @param \Symfony\Component\Console\Command\Command|string $command + * @param array $arguments + * @return int + */ + public function callSilently($command, array $arguments = []) + { + return $this->callSilent($command, $arguments); + } + /** * Run the given the console command. * diff --git a/src/Illuminate/Console/Concerns/CreatesMatchingTest.php b/src/Illuminate/Console/Concerns/CreatesMatchingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a360c281a98aa7957ddc16d529ea72602e74fc77 --- /dev/null +++ b/src/Illuminate/Console/Concerns/CreatesMatchingTest.php @@ -0,0 +1,44 @@ +<?php + +namespace Illuminate\Console\Concerns; + +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputOption; + +trait CreatesMatchingTest +{ + /** + * Add the standard command options for generating matching tests. + * + * @return void + */ + protected function addTestOptions() + { + foreach (['test' => 'PHPUnit', 'pest' => 'Pest'] as $option => $name) { + $this->getDefinition()->addOption(new InputOption( + $option, + null, + InputOption::VALUE_NONE, + "Generate an accompanying {$name} test for the {$this->type}" + )); + } + } + + /** + * Create the matching test case if requested. + * + * @param string $path + * @return void + */ + protected function handleTestCreation($path) + { + if (! $this->option('test') && ! $this->option('pest')) { + return; + } + + $this->call('make:test', [ + 'name' => Str::of($path)->after($this->laravel['path'])->beforeLast('.php')->append('Test')->replace('\\', '/'), + '--pest' => $this->option('pest'), + ]); + } +} diff --git a/src/Illuminate/Console/Concerns/InteractsWithIO.php b/src/Illuminate/Console/Concerns/InteractsWithIO.php index 9e9123fc35ab4c7780101e0c5d397ec45d3bedbe..69d295c1efb1c67e0cd1ea10add297d6873d7de3 100644 --- a/src/Illuminate/Console/Concerns/InteractsWithIO.php +++ b/src/Illuminate/Console/Concerns/InteractsWithIO.php @@ -2,6 +2,7 @@ namespace Illuminate\Console\Concerns; +use Closure; use Illuminate\Console\OutputStyle; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Str; @@ -199,10 +200,10 @@ trait InteractsWithIO * @param array $choices * @param string|null $default * @param mixed|null $attempts - * @param bool|null $multiple - * @return string + * @param bool $multiple + * @return string|array */ - public function choice($question, array $choices, $default = null, $attempts = null, $multiple = null) + public function choice($question, array $choices, $default = null, $attempts = null, $multiple = false) { $question = new ChoiceQuestion($question, $choices, $default); @@ -237,6 +238,38 @@ trait InteractsWithIO $table->render(); } + /** + * Execute a given callback while advancing a progress bar. + * + * @param iterable|int $totalSteps + * @param \Closure $callback + * @return mixed|void + */ + public function withProgressBar($totalSteps, Closure $callback) + { + $bar = $this->output->createProgressBar( + is_iterable($totalSteps) ? count($totalSteps) : $totalSteps + ); + + $bar->start(); + + if (is_iterable($totalSteps)) { + foreach ($totalSteps as $value) { + $callback($value, $bar); + + $bar->advance(); + } + } else { + $callback($bar); + } + + $bar->finish(); + + if (is_iterable($totalSteps)) { + return $totalSteps; + } + } + /** * Write a string as information output. * @@ -332,7 +365,18 @@ trait InteractsWithIO $this->comment('* '.$string.' *'); $this->comment(str_repeat('*', $length)); - $this->output->newLine(); + $this->newLine(); + } + + /** + * Write a blank line. + * + * @param int $count + * @return void + */ + public function newLine($count = 1) + { + $this->output->newLine($count); } /** diff --git a/src/Illuminate/Console/DetectsApplicationNamespace.php b/src/Illuminate/Console/DetectsApplicationNamespace.php deleted file mode 100644 index 391b6c684a8e258a1d72aea6599f2513e647d8cc..0000000000000000000000000000000000000000 --- a/src/Illuminate/Console/DetectsApplicationNamespace.php +++ /dev/null @@ -1,21 +0,0 @@ -<?php - -namespace Illuminate\Console; - -use Illuminate\Container\Container; - -/** - * @deprecated Usage of this trait is deprecated and it will be removed in Laravel 7.0. - */ -trait DetectsApplicationNamespace -{ - /** - * Get the application namespace. - * - * @return string - */ - protected function getAppNamespace() - { - return Container::getInstance()->getNamespace(); - } -} diff --git a/src/Illuminate/Console/Events/ScheduledBackgroundTaskFinished.php b/src/Illuminate/Console/Events/ScheduledBackgroundTaskFinished.php new file mode 100644 index 0000000000000000000000000000000000000000..d9e63c2e58d5c2fbf8c48d809effc57ec84a9869 --- /dev/null +++ b/src/Illuminate/Console/Events/ScheduledBackgroundTaskFinished.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Console\Events; + +use Illuminate\Console\Scheduling\Event; + +class ScheduledBackgroundTaskFinished +{ + /** + * The scheduled event that ran. + * + * @var \Illuminate\Console\Scheduling\Event + */ + public $task; + + /** + * Create a new event instance. + * + * @param \Illuminate\Console\Scheduling\Event $task + * @return void + */ + public function __construct(Event $task) + { + $this->task = $task; + } +} diff --git a/src/Illuminate/Console/Events/ScheduledTaskFailed.php b/src/Illuminate/Console/Events/ScheduledTaskFailed.php new file mode 100644 index 0000000000000000000000000000000000000000..46857ad849a7c9e6a7a450182ba809905fc5ec29 --- /dev/null +++ b/src/Illuminate/Console/Events/ScheduledTaskFailed.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Console\Events; + +use Illuminate\Console\Scheduling\Event; +use Throwable; + +class ScheduledTaskFailed +{ + /** + * The scheduled event that failed. + * + * @var \Illuminate\Console\Scheduling\Event + */ + public $task; + + /** + * The exception that was thrown. + * + * @var \Throwable + */ + public $exception; + + /** + * Create a new event instance. + * + * @param \Illuminate\Console\Scheduling\Event $task + * @param \Throwable $exception + * @return void + */ + public function __construct(Event $task, Throwable $exception) + { + $this->task = $task; + $this->exception = $exception; + } +} diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 9d2ea99b7b0157baf906c973cae7778966f54619..5c12e05ed094e4e22906a646a807fd5846625e87 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -2,6 +2,7 @@ namespace Illuminate\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputArgument; @@ -22,6 +23,82 @@ abstract class GeneratorCommand extends Command */ protected $type; + /** + * Reserved names that cannot be used for generation. + * + * @var string[] + */ + protected $reservedNames = [ + '__halt_compiler', + 'abstract', + 'and', + 'array', + 'as', + 'break', + 'callable', + 'case', + 'catch', + 'class', + 'clone', + 'const', + 'continue', + 'declare', + 'default', + 'die', + 'do', + 'echo', + 'else', + 'elseif', + 'empty', + 'enddeclare', + 'endfor', + 'endforeach', + 'endif', + 'endswitch', + 'endwhile', + 'eval', + 'exit', + 'extends', + 'final', + 'finally', + 'fn', + 'for', + 'foreach', + 'function', + 'global', + 'goto', + 'if', + 'implements', + 'include', + 'include_once', + 'instanceof', + 'insteadof', + 'interface', + 'isset', + 'list', + 'namespace', + 'new', + 'or', + 'print', + 'private', + 'protected', + 'public', + 'require', + 'require_once', + 'return', + 'static', + 'switch', + 'throw', + 'trait', + 'try', + 'unset', + 'use', + 'var', + 'while', + 'xor', + 'yield', + ]; + /** * Create a new controller creator command instance. * @@ -32,6 +109,10 @@ abstract class GeneratorCommand extends Command { parent::__construct(); + if (in_array(CreatesMatchingTest::class, class_uses_recursive($this))) { + $this->addTestOptions(); + } + $this->files = $files; } @@ -51,11 +132,20 @@ abstract class GeneratorCommand extends Command */ public function handle() { + // First we need to ensure that the given name is not a reserved word within the PHP + // language and that the class name will actually be valid. If it is not valid we + // can error now and prevent from polluting the filesystem using invalid files. + if ($this->isReservedName($this->getNameInput())) { + $this->error('The name "'.$this->getNameInput().'" is reserved by PHP.'); + + return false; + } + $name = $this->qualifyClass($this->getNameInput()); $path = $this->getPath($name); - // First we will check to see if the class already exists. If it does, we don't want + // Next, We will check to see if the class already exists. If it does, we don't want // to create the class and overwrite the user's code. So, we will bail out so the // code is untouched. Otherwise, we will continue generating this class' files. if ((! $this->hasOption('force') || @@ -74,6 +164,10 @@ abstract class GeneratorCommand extends Command $this->files->put($path, $this->sortImports($this->buildClass($name))); $this->info($this->type.' created successfully.'); + + if (in_array(CreatesMatchingTest::class, class_uses_recursive($this))) { + $this->handleTestCreation($path); + } } /** @@ -86,19 +180,42 @@ abstract class GeneratorCommand extends Command { $name = ltrim($name, '\\/'); + $name = str_replace('/', '\\', $name); + $rootNamespace = $this->rootNamespace(); if (Str::startsWith($name, $rootNamespace)) { return $name; } - $name = str_replace('/', '\\', $name); - return $this->qualifyClass( $this->getDefaultNamespace(trim($rootNamespace, '\\')).'\\'.$name ); } + /** + * Qualify the given model class base name. + * + * @param string $model + * @return string + */ + protected function qualifyModel(string $model) + { + $model = ltrim($model, '\\/'); + + $model = str_replace('/', '\\', $model); + + $rootNamespace = $this->rootNamespace(); + + if (Str::startsWith($model, $rootNamespace)) { + return $model; + } + + return is_dir(app_path('Models')) + ? $rootNamespace.'Models\\'.$model + : $rootNamespace.$model; + } + /** * Get the default namespace for the class. * @@ -173,11 +290,19 @@ abstract class GeneratorCommand extends Command */ protected function replaceNamespace(&$stub, $name) { - $stub = str_replace( + $searches = [ ['DummyNamespace', 'DummyRootNamespace', 'NamespacedDummyUserModel'], - [$this->getNamespace($name), $this->rootNamespace(), $this->userProviderModel()], - $stub - ); + ['{{ namespace }}', '{{ rootNamespace }}', '{{ namespacedUserModel }}'], + ['{{namespace}}', '{{rootNamespace}}', '{{namespacedUserModel}}'], + ]; + + foreach ($searches as $search) { + $stub = str_replace( + $search, + [$this->getNamespace($name), $this->rootNamespace(), $this->userProviderModel()], + $stub + ); + } return $this; } @@ -204,7 +329,7 @@ abstract class GeneratorCommand extends Command { $class = str_replace($this->getNamespace($name).'\\', '', $name); - return str_replace('DummyClass', $class, $stub); + return str_replace(['DummyClass', '{{ class }}', '{{class}}'], $class, $stub); } /** @@ -260,6 +385,32 @@ abstract class GeneratorCommand extends Command return $config->get("auth.providers.{$provider}.model"); } + /** + * Checks whether the given name is reserved. + * + * @param string $name + * @return bool + */ + protected function isReservedName($name) + { + $name = strtolower($name); + + return in_array($name, $this->reservedNames); + } + + /** + * Get the first view directory path from the application configuration. + * + * @param string $path + * @return string + */ + protected function viewPath($path = '') + { + $views = $this->laravel['config']['view.paths'][0] ?? resource_path('views'); + + return $views.($path ? DIRECTORY_SEPARATOR.$path : $path); + } + /** * Get the console command arguments. * diff --git a/src/Illuminate/Console/OutputStyle.php b/src/Illuminate/Console/OutputStyle.php index fe5dc450ca458b61d06fdcc53ee80b963cfcc6eb..1e7c153145e36a8a43f38d8f8c1c65cf0ef70556 100644 --- a/src/Illuminate/Console/OutputStyle.php +++ b/src/Illuminate/Console/OutputStyle.php @@ -68,4 +68,14 @@ class OutputStyle extends SymfonyStyle { return $this->output->isDebug(); } + + /** + * Get the underlying Symfony output implementation. + * + * @return \Symfony\Component\Console\Output\OutputInterface + */ + public function getOutput() + { + return $this->output; + } } diff --git a/src/Illuminate/Console/Scheduling/CacheAware.php b/src/Illuminate/Console/Scheduling/CacheAware.php new file mode 100644 index 0000000000000000000000000000000000000000..862c47f5e2c5fd6bd916ae54f9c71394ec8c7019 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/CacheAware.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Console\Scheduling; + +interface CacheAware +{ + /** + * Specify the cache store that should be used. + * + * @param string $store + * @return $this + */ + public function useStore($store); +} diff --git a/src/Illuminate/Console/Scheduling/CacheEventMutex.php b/src/Illuminate/Console/Scheduling/CacheEventMutex.php index acf41edeec019f690e13a4996a66e06adeba1f4c..1f6b15eacbeaff5f74dfbf1603024370d2e69b26 100644 --- a/src/Illuminate/Console/Scheduling/CacheEventMutex.php +++ b/src/Illuminate/Console/Scheduling/CacheEventMutex.php @@ -4,7 +4,7 @@ namespace Illuminate\Console\Scheduling; use Illuminate\Contracts\Cache\Factory as Cache; -class CacheEventMutex implements EventMutex +class CacheEventMutex implements EventMutex, CacheAware { /** * The cache repository implementation. diff --git a/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php b/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php index 0dffb56799c1936badd87251efc5d239066ae8cb..ca8e2cb881f727bd81901b60c23ac5796b9326a8 100644 --- a/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php +++ b/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php @@ -5,7 +5,7 @@ namespace Illuminate\Console\Scheduling; use DateTimeInterface; use Illuminate\Contracts\Cache\Factory as Cache; -class CacheSchedulingMutex implements SchedulingMutex +class CacheSchedulingMutex implements SchedulingMutex, CacheAware { /** * The cache factory implementation. diff --git a/src/Illuminate/Console/Scheduling/CallbackEvent.php b/src/Illuminate/Console/Scheduling/CallbackEvent.php index 6af680d990c03b8b7a9b553f5e71adb55401ec55..dde5d7dea5494cf12d3ea177ddc1e78c189b350c 100644 --- a/src/Illuminate/Console/Scheduling/CallbackEvent.php +++ b/src/Illuminate/Console/Scheduling/CallbackEvent.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Support\Reflector; use InvalidArgumentException; use LogicException; +use Throwable; class CallbackEvent extends Event { @@ -77,6 +78,12 @@ class CallbackEvent extends Event $response = is_object($this->callback) ? $container->call([$this->callback, '__invoke'], $this->parameters) : $container->call($this->callback, $this->parameters); + + $this->exitCode = $response === false ? 1 : 0; + } catch (Throwable $e) { + $this->exitCode = 1; + + throw $e; } finally { $this->removeMutex(); diff --git a/src/Illuminate/Console/Scheduling/CommandBuilder.php b/src/Illuminate/Console/Scheduling/CommandBuilder.php index bc833bd2710c28f931445803ef8c499773f0e0b2..ee13c5ee372d1af31bea9c43f4fdfafefe790a64 100644 --- a/src/Illuminate/Console/Scheduling/CommandBuilder.php +++ b/src/Illuminate/Console/Scheduling/CommandBuilder.php @@ -52,7 +52,7 @@ class CommandBuilder $finished = Application::formatCommandString('schedule:finish').' "'.$event->mutexName().'"'; if (windows_os()) { - return 'start /b cmd /c "('.$event->command.' & '.$finished.' "%errorlevel%")'.$redirect.$output.' 2>&1"'; + return 'start /b cmd /v:on /c "('.$event->command.' & '.$finished.' ^!ERRORLEVEL^!)'.$redirect.$output.' 2>&1"'; } return $this->ensureCorrectUser($event, diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index 49ce518bc33708d492586ea166a683932cef60d8..4de88f163dbf6c303761680caf164df00c51fd22 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -10,16 +10,18 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Illuminate\Support\Reflector; +use Illuminate\Support\Stringable; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\ReflectsClosures; use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\Process\Process; +use Throwable; class Event { - use Macroable, ManagesFrequencies; + use Macroable, ManagesFrequencies, ReflectsClosures; /** * The command string. @@ -85,7 +87,7 @@ class Event public $expiresAt = 1440; /** - * Indicates if the command should run in background. + * Indicates if the command should run in the background. * * @var bool */ @@ -217,11 +219,17 @@ class Event */ protected function runCommandInForeground(Container $container) { - $this->callBeforeCallbacks($container); + try { + $this->callBeforeCallbacks($container); - $this->exitCode = Process::fromShellCommandline($this->buildCommand(), base_path(), null, null, null)->run(); + $this->exitCode = Process::fromShellCommandline( + $this->buildCommand(), base_path(), null, null, null + )->run(); - $this->callAfterCallbacks($container); + $this->callAfterCallbacks($container); + } finally { + $this->removeMutex(); + } } /** @@ -232,9 +240,15 @@ class Event */ protected function runCommandInBackground(Container $container) { - $this->callBeforeCallbacks($container); + try { + $this->callBeforeCallbacks($container); + + Process::fromShellCommandline($this->buildCommand(), base_path(), null, null, null)->run(); + } catch (Throwable $exception) { + $this->removeMutex(); - Process::fromShellCommandline($this->buildCommand(), base_path(), null, null, null)->run(); + throw $exception; + } } /** @@ -274,7 +288,11 @@ class Event { $this->exitCode = (int) $exitCode; - $this->callAfterCallbacks($container); + try { + $this->callAfterCallbacks($container); + } finally { + $this->removeMutex(); + } } /** @@ -320,13 +338,13 @@ class Event */ protected function expressionPasses() { - $date = Carbon::now(); + $date = Date::now(); if ($this->timezone) { - $date->setTimezone($this->timezone); + $date = $date->setTimezone($this->timezone); } - return CronExpression::factory($this->expression)->isDue($date->toDateTimeString()); + return (new CronExpression($this->expression))->isDue($date->toDateTimeString()); } /** @@ -474,7 +492,7 @@ class Event */ protected function emailOutput(Mailer $mailer, $addresses, $onlyIfOutputExists = false) { - $text = file_exists($this->output) ? file_get_contents($this->output) : ''; + $text = is_file($this->output) ? file_get_contents($this->output) : ''; if ($onlyIfOutputExists && empty($text)) { return; @@ -577,15 +595,15 @@ class Event { return function (Container $container, HttpClient $http) use ($url) { try { - $http->get($url); - } catch (ClientExceptionInterface | TransferException $e) { + $http->request('GET', $url); + } catch (ClientExceptionInterface|TransferException $e) { $container->make(ExceptionHandler::class)->report($e); } }; } /** - * State that the command should run in background. + * State that the command should run in the background. * * @return $this */ @@ -646,9 +664,7 @@ class Event $this->expiresAt = $expiresAt; - return $this->then(function () { - $this->mutex->forget($this); - })->skip(function () { + return $this->skip(function () { return $this->mutex->exists($this); }); } @@ -727,11 +743,31 @@ class Event */ public function then(Closure $callback) { + $parameters = $this->closureParameterTypes($callback); + + if (Arr::get($parameters, 'output') === Stringable::class) { + return $this->thenWithOutput($callback); + } + $this->afterCallbacks[] = $callback; return $this; } + /** + * Register a callback that uses the output after the job runs. + * + * @param \Closure $callback + * @param bool $onlyIfOutputExists + * @return $this + */ + public function thenWithOutput(Closure $callback, $onlyIfOutputExists = false) + { + $this->ensureOutputIsBeingCaptured(); + + return $this->then($this->withOutputCallback($callback, $onlyIfOutputExists)); + } + /** * Register a callback to be called if the operation succeeds. * @@ -740,6 +776,12 @@ class Event */ public function onSuccess(Closure $callback) { + $parameters = $this->closureParameterTypes($callback); + + if (Arr::get($parameters, 'output') === Stringable::class) { + return $this->onSuccessWithOutput($callback); + } + return $this->then(function (Container $container) use ($callback) { if (0 === $this->exitCode) { $container->call($callback); @@ -747,6 +789,20 @@ class Event }); } + /** + * Register a callback that uses the output if the operation succeeds. + * + * @param \Closure $callback + * @param bool $onlyIfOutputExists + * @return $this + */ + public function onSuccessWithOutput(Closure $callback, $onlyIfOutputExists = false) + { + $this->ensureOutputIsBeingCaptured(); + + return $this->onSuccess($this->withOutputCallback($callback, $onlyIfOutputExists)); + } + /** * Register a callback to be called if the operation fails. * @@ -755,6 +811,12 @@ class Event */ public function onFailure(Closure $callback) { + $parameters = $this->closureParameterTypes($callback); + + if (Arr::get($parameters, 'output') === Stringable::class) { + return $this->onFailureWithOutput($callback); + } + return $this->then(function (Container $container) use ($callback) { if (0 !== $this->exitCode) { $container->call($callback); @@ -762,6 +824,38 @@ class Event }); } + /** + * Register a callback that uses the output if the operation fails. + * + * @param \Closure $callback + * @param bool $onlyIfOutputExists + * @return $this + */ + public function onFailureWithOutput(Closure $callback, $onlyIfOutputExists = false) + { + $this->ensureOutputIsBeingCaptured(); + + return $this->onFailure($this->withOutputCallback($callback, $onlyIfOutputExists)); + } + + /** + * Get a callback that provides output. + * + * @param \Closure $callback + * @param bool $onlyIfOutputExists + * @return \Closure + */ + protected function withOutputCallback(Closure $callback, $onlyIfOutputExists = false) + { + return function (Container $container) use ($callback, $onlyIfOutputExists) { + $output = $this->output && is_file($this->output) ? file_get_contents($this->output) : ''; + + return $onlyIfOutputExists && empty($output) + ? null + : $container->call($callback, ['output' => new Stringable($output)]); + }; + } + /** * Set the human-friendly description of the event. * @@ -810,9 +904,8 @@ class Event */ public function nextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) { - return Date::instance(CronExpression::factory( - $this->getExpression() - )->getNextRunDate($currentTime, $nth, $allowCurrentDate, $this->timezone)); + return Date::instance((new CronExpression($this->getExpression())) + ->getNextRunDate($currentTime, $nth, $allowCurrentDate, $this->timezone)); } /** @@ -837,4 +930,16 @@ class Event return $this; } + + /** + * Delete the mutex for the event. + * + * @return void + */ + protected function removeMutex() + { + if ($this->withoutOverlapping) { + $this->mutex->forget($this); + } + } } diff --git a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php index afbf178fbffe7ba19b215b143d6bbf3ce1062e50..9226cec2701f516ad9c5788ef7f3ceb918bc5a81 100644 --- a/src/Illuminate/Console/Scheduling/ManagesFrequencies.php +++ b/src/Illuminate/Console/Scheduling/ManagesFrequencies.php @@ -81,6 +81,36 @@ trait ManagesFrequencies return $this->spliceIntoPosition(1, '*'); } + /** + * Schedule the event to run every two minutes. + * + * @return $this + */ + public function everyTwoMinutes() + { + return $this->spliceIntoPosition(1, '*/2'); + } + + /** + * Schedule the event to run every three minutes. + * + * @return $this + */ + public function everyThreeMinutes() + { + return $this->spliceIntoPosition(1, '*/3'); + } + + /** + * Schedule the event to run every four minutes. + * + * @return $this + */ + public function everyFourMinutes() + { + return $this->spliceIntoPosition(1, '*/4'); + } + /** * Schedule the event to run every five minutes. * @@ -144,6 +174,50 @@ trait ManagesFrequencies return $this->spliceIntoPosition(1, $offset); } + /** + * Schedule the event to run every two hours. + * + * @return $this + */ + public function everyTwoHours() + { + return $this->spliceIntoPosition(1, 0) + ->spliceIntoPosition(2, '*/2'); + } + + /** + * Schedule the event to run every three hours. + * + * @return $this + */ + public function everyThreeHours() + { + return $this->spliceIntoPosition(1, 0) + ->spliceIntoPosition(2, '*/3'); + } + + /** + * Schedule the event to run every four hours. + * + * @return $this + */ + public function everyFourHours() + { + return $this->spliceIntoPosition(1, 0) + ->spliceIntoPosition(2, '*/4'); + } + + /** + * Schedule the event to run every six hours. + * + * @return $this + */ + public function everySixHours() + { + return $this->spliceIntoPosition(1, 0) + ->spliceIntoPosition(2, '*/6'); + } + /** * Schedule the event to run daily. * @@ -188,10 +262,23 @@ trait ManagesFrequencies * @return $this */ public function twiceDaily($first = 1, $second = 13) + { + return $this->twiceDailyAt($first, $second, 0); + } + + /** + * Schedule the event to run twice daily at a given offset. + * + * @param int $first + * @param int $second + * @param int $offset + * @return $this + */ + public function twiceDailyAt($first = 1, $second = 13, $offset = 0) { $hours = $first.','.$second; - return $this->spliceIntoPosition(1, 0) + return $this->spliceIntoPosition(1, $offset) ->spliceIntoPosition(2, $hours); } @@ -202,7 +289,7 @@ trait ManagesFrequencies */ public function weekdays() { - return $this->spliceIntoPosition(5, '1-5'); + return $this->days(Schedule::MONDAY.'-'.Schedule::FRIDAY); } /** @@ -212,7 +299,7 @@ trait ManagesFrequencies */ public function weekends() { - return $this->spliceIntoPosition(5, '0,6'); + return $this->days(Schedule::SATURDAY.','.Schedule::SUNDAY); } /** @@ -222,7 +309,7 @@ trait ManagesFrequencies */ public function mondays() { - return $this->days(1); + return $this->days(Schedule::MONDAY); } /** @@ -232,7 +319,7 @@ trait ManagesFrequencies */ public function tuesdays() { - return $this->days(2); + return $this->days(Schedule::TUESDAY); } /** @@ -242,7 +329,7 @@ trait ManagesFrequencies */ public function wednesdays() { - return $this->days(3); + return $this->days(Schedule::WEDNESDAY); } /** @@ -252,7 +339,7 @@ trait ManagesFrequencies */ public function thursdays() { - return $this->days(4); + return $this->days(Schedule::THURSDAY); } /** @@ -262,7 +349,7 @@ trait ManagesFrequencies */ public function fridays() { - return $this->days(5); + return $this->days(Schedule::FRIDAY); } /** @@ -272,7 +359,7 @@ trait ManagesFrequencies */ public function saturdays() { - return $this->days(6); + return $this->days(Schedule::SATURDAY); } /** @@ -282,7 +369,7 @@ trait ManagesFrequencies */ public function sundays() { - return $this->days(0); + return $this->days(Schedule::SUNDAY); } /** @@ -300,15 +387,15 @@ trait ManagesFrequencies /** * Schedule the event to run weekly on a given day and time. * - * @param int $day + * @param array|mixed $dayOfWeek * @param string $time * @return $this */ - public function weeklyOn($day, $time = '0:0') + public function weeklyOn($dayOfWeek, $time = '0:0') { $this->dailyAt($time); - return $this->spliceIntoPosition(5, $day); + return $this->days($dayOfWeek); } /** @@ -326,31 +413,45 @@ trait ManagesFrequencies /** * Schedule the event to run monthly on a given day and time. * - * @param int $day + * @param int $dayOfMonth * @param string $time * @return $this */ - public function monthlyOn($day = 1, $time = '0:0') + public function monthlyOn($dayOfMonth = 1, $time = '0:0') { $this->dailyAt($time); - return $this->spliceIntoPosition(3, $day); + return $this->spliceIntoPosition(3, $dayOfMonth); } /** - * Schedule the event to run twice monthly. + * Schedule the event to run twice monthly at a given time. * * @param int $first * @param int $second + * @param string $time * @return $this */ - public function twiceMonthly($first = 1, $second = 16) + public function twiceMonthly($first = 1, $second = 16, $time = '0:0') { - $days = $first.','.$second; + $daysOfMonth = $first.','.$second; - return $this->spliceIntoPosition(1, 0) - ->spliceIntoPosition(2, 0) - ->spliceIntoPosition(3, $days); + $this->dailyAt($time); + + return $this->spliceIntoPosition(3, $daysOfMonth); + } + + /** + * Schedule the event to run on the last day of the month. + * + * @param string $time + * @return $this + */ + public function lastDayOfMonth($time = '0:0') + { + $this->dailyAt($time); + + return $this->spliceIntoPosition(3, Carbon::now()->endOfMonth()->day); } /** @@ -379,6 +480,22 @@ trait ManagesFrequencies ->spliceIntoPosition(4, 1); } + /** + * Schedule the event to run yearly on a given month, day, and time. + * + * @param int $month + * @param int|string $dayOfMonth + * @param string $time + * @return $this + */ + public function yearlyOn($month = 1, $dayOfMonth = 1, $time = '0:0') + { + $this->dailyAt($time); + + return $this->spliceIntoPosition(3, $dayOfMonth) + ->spliceIntoPosition(4, $month); + } + /** * Set the days of the week the command should run on. * diff --git a/src/Illuminate/Console/Scheduling/Schedule.php b/src/Illuminate/Console/Scheduling/Schedule.php index bfbb4141a6d6ff24038e89b5522122add7e41c26..ee5412b7b48ef46fb2c6b56fe1a7ef1c14153b91 100644 --- a/src/Illuminate/Console/Scheduling/Schedule.php +++ b/src/Illuminate/Console/Scheduling/Schedule.php @@ -4,10 +4,13 @@ namespace Illuminate\Console\Scheduling; use Closure; use DateTimeInterface; +use Illuminate\Bus\UniqueLock; use Illuminate\Console\Application; use Illuminate\Container\Container; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\CallQueuedClosure; use Illuminate\Support\ProcessUtils; @@ -19,6 +22,14 @@ class Schedule { use Macroable; + const SUNDAY = 0; + const MONDAY = 1; + const TUESDAY = 2; + const WEDNESDAY = 3; + const THURSDAY = 4; + const FRIDAY = 5; + const SATURDAY = 6; + /** * All of the events on the schedule. * @@ -59,6 +70,8 @@ class Schedule * * @param \DateTimeZone|string|null $timezone * @return void + * + * @throws \RuntimeException */ public function __construct($timezone = null) { @@ -107,7 +120,11 @@ class Schedule public function command($command, array $parameters = []) { if (class_exists($command)) { - $command = Container::getInstance()->make($command)->getName(); + $command = Container::getInstance()->make($command); + + return $this->exec( + Application::formatCommandString($command->getName()), $parameters, + )->description($command->getDescription()); } return $this->exec( @@ -143,6 +160,8 @@ class Schedule * @param string|null $queue * @param string|null $connection * @return void + * + * @throws \RuntimeException */ protected function dispatchToQueue($job, $queue, $connection) { @@ -156,6 +175,35 @@ class Schedule $job = CallQueuedClosure::create($job); } + if ($job instanceof ShouldBeUnique) { + return $this->dispatchUniqueJobToQueue($job, $queue, $connection); + } + + $this->getDispatcher()->dispatch( + $job->onConnection($connection)->onQueue($queue) + ); + } + + /** + * Dispatch the given unique job to the queue. + * + * @param object $job + * @param string|null $queue + * @param string|null $connection + * @return void + * + * @throws \RuntimeException + */ + protected function dispatchUniqueJobToQueue($job, $queue, $connection) + { + if (! Container::getInstance()->bound(Cache::class)) { + throw new RuntimeException('Cache driver not available. Scheduling unique jobs not supported.'); + } + + if (! (new UniqueLock(Container::getInstance()->make(Cache::class)))->acquire($job)) { + return; + } + $this->getDispatcher()->dispatch( $job->onConnection($connection)->onQueue($queue) ); @@ -278,11 +326,11 @@ class Schedule */ public function useCache($store) { - if ($this->eventMutex instanceof CacheEventMutex) { + if ($this->eventMutex instanceof CacheAware) { $this->eventMutex->useStore($store); } - if ($this->schedulingMutex instanceof CacheSchedulingMutex) { + if ($this->schedulingMutex instanceof CacheAware) { $this->schedulingMutex->useStore($store); } @@ -293,6 +341,8 @@ class Schedule * Get the job dispatcher, if available. * * @return \Illuminate\Contracts\Bus\Dispatcher + * + * @throws \RuntimeException */ protected function getDispatcher() { diff --git a/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php b/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..0dd9424c4bd3263c4bdbaf8aefadab82bc4fb382 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php @@ -0,0 +1,47 @@ +<?php + +namespace Illuminate\Console\Scheduling; + +use Illuminate\Console\Command; + +class ScheduleClearCacheCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'schedule:clear-cache'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Delete the cached mutex files created by scheduler'; + + /** + * Execute the console command. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + public function handle(Schedule $schedule) + { + $mutexCleared = false; + + foreach ($schedule->events($this->laravel) as $event) { + if ($event->mutex->exists($event)) { + $this->line('<info>Deleting mutex for:</info> '.$event->command); + + $event->mutex->forget($event); + + $mutexCleared = true; + } + } + + if (! $mutexCleared) { + $this->info('No mutex files were found.'); + } + } +} diff --git a/src/Illuminate/Console/Scheduling/ScheduleFinishCommand.php b/src/Illuminate/Console/Scheduling/ScheduleFinishCommand.php index c19381f08a51bf387e413bc640768498a637fc16..4857d695ca910b6785739032571efbe88e303d31 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleFinishCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleFinishCommand.php @@ -3,6 +3,8 @@ namespace Illuminate\Console\Scheduling; use Illuminate\Console\Command; +use Illuminate\Console\Events\ScheduledBackgroundTaskFinished; +use Illuminate\Contracts\Events\Dispatcher; class ScheduleFinishCommand extends Command { @@ -37,6 +39,10 @@ class ScheduleFinishCommand extends Command { collect($schedule->events())->filter(function ($value) { return $value->mutexName() == $this->argument('id'); - })->each->callAfterCallbacksWithExitCode($this->laravel, $this->argument('code')); + })->each(function ($event) { + $event->callafterCallbacksWithExitCode($this->laravel, $this->argument('code')); + + $this->laravel->make(Dispatcher::class)->dispatch(new ScheduledBackgroundTaskFinished($event)); + }); } } diff --git a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..b1b0bdde59ff354df72590ac746493eaf66b8c53 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php @@ -0,0 +1,55 @@ +<?php + +namespace Illuminate\Console\Scheduling; + +use Cron\CronExpression; +use DateTimeZone; +use Illuminate\Console\Command; +use Illuminate\Support\Carbon; + +class ScheduleListCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $signature = 'schedule:list {--timezone= : The timezone that times should be displayed in}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'List the scheduled commands'; + + /** + * Execute the console command. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + * + * @throws \Exception + */ + public function handle(Schedule $schedule) + { + foreach ($schedule->events() as $event) { + $rows[] = [ + $event->command, + $event->expression, + $event->description, + (new CronExpression($event->expression)) + ->getNextRunDate(Carbon::now()->setTimezone($event->timezone)) + ->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone'))) + ->format('Y-m-d H:i:s P'), + ]; + } + + $this->table([ + 'Command', + 'Interval', + 'Description', + 'Next Due', + ], $rows ?? []); + } +} diff --git a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php index b211da1402b6892a1d198dfba7b7d9969b7a5e4c..4193408fc079fa39f1c56e89fcc46eba6614f661 100644 --- a/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php +++ b/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php @@ -3,11 +3,14 @@ namespace Illuminate\Console\Scheduling; use Illuminate\Console\Command; +use Illuminate\Console\Events\ScheduledTaskFailed; use Illuminate\Console\Events\ScheduledTaskFinished; use Illuminate\Console\Events\ScheduledTaskSkipped; use Illuminate\Console\Events\ScheduledTaskStarting; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\Date; +use Throwable; class ScheduleRunCommand extends Command { @@ -53,6 +56,13 @@ class ScheduleRunCommand extends Command */ protected $dispatcher; + /** + * The exception handler. + * + * @var \Illuminate\Contracts\Debug\ExceptionHandler + */ + protected $handler; + /** * Create a new command instance. * @@ -70,12 +80,14 @@ class ScheduleRunCommand extends Command * * @param \Illuminate\Console\Scheduling\Schedule $schedule * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher + * @param \Illuminate\Contracts\Debug\ExceptionHandler $handler * @return void */ - public function handle(Schedule $schedule, Dispatcher $dispatcher) + public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler) { $this->schedule = $schedule; $this->dispatcher = $dispatcher; + $this->handler = $handler; foreach ($this->schedule->dueEvents($this->laravel) as $event) { if (! $event->filtersPass($this->laravel)) { @@ -121,19 +133,25 @@ class ScheduleRunCommand extends Command */ protected function runEvent($event) { - $this->line('<info>Running scheduled command:</info> '.$event->getSummaryForDisplay()); + $this->line('<info>['.date('c').'] Running scheduled command:</info> '.$event->getSummaryForDisplay()); $this->dispatcher->dispatch(new ScheduledTaskStarting($event)); $start = microtime(true); - $event->run($this->laravel); + try { + $event->run($this->laravel); - $this->dispatcher->dispatch(new ScheduledTaskFinished( - $event, - round(microtime(true) - $start, 2) - )); + $this->dispatcher->dispatch(new ScheduledTaskFinished( + $event, + round(microtime(true) - $start, 2) + )); - $this->eventsRan = true; + $this->eventsRan = true; + } catch (Throwable $e) { + $this->dispatcher->dispatch(new ScheduledTaskFailed($event, $e)); + + $this->handler->report($e); + } } } diff --git a/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php b/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..281886439faa2d739a6ba7d215544adde21930b8 --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php @@ -0,0 +1,47 @@ +<?php + +namespace Illuminate\Console\Scheduling; + +use Illuminate\Console\Command; + +class ScheduleTestCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'schedule:test'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Run a scheduled command'; + + /** + * Execute the console command. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + public function handle(Schedule $schedule) + { + $commands = $schedule->events(); + + $commandNames = []; + + foreach ($commands as $command) { + $commandNames[] = $command->command ?? $command->getSummaryForDisplay(); + } + + $index = array_search($this->choice('Which command would you like to run?', $commandNames), $commandNames); + + $event = $commands[$index]; + + $this->line('<info>['.date('c').'] Running scheduled command:</info> '.$event->getSummaryForDisplay()); + + $event->run($this->laravel); + } +} diff --git a/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php b/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..165124a79050af9d4d93a7390aee4b0a8d5749cc --- /dev/null +++ b/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php @@ -0,0 +1,72 @@ +<?php + +namespace Illuminate\Console\Scheduling; + +use Illuminate\Console\Command; +use Illuminate\Support\Carbon; +use Symfony\Component\Process\Process; + +class ScheduleWorkCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'schedule:work'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Start the schedule worker'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $this->info('Schedule worker started successfully.'); + + [$lastExecutionStartedAt, $keyOfLastExecutionWithOutput, $executions] = [null, null, []]; + + while (true) { + usleep(100 * 1000); + + if (Carbon::now()->second === 0 && + ! Carbon::now()->startOfMinute()->equalTo($lastExecutionStartedAt)) { + $executions[] = $execution = new Process([ + PHP_BINARY, + defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', + 'schedule:run', + ]); + + $execution->start(); + + $lastExecutionStartedAt = Carbon::now()->startOfMinute(); + } + + foreach ($executions as $key => $execution) { + $output = trim($execution->getIncrementalOutput()). + trim($execution->getIncrementalErrorOutput()); + + if (! empty($output)) { + if ($key !== $keyOfLastExecutionWithOutput) { + $this->info(PHP_EOL.'['.date('c').'] Execution #'.($key + 1).' output:'); + + $keyOfLastExecutionWithOutput = $key; + } + + $this->output->writeln($output); + } + + if (! $execution->isRunning()) { + unset($executions[$key]); + } + } + } + } +} diff --git a/src/Illuminate/Console/composer.json b/src/Illuminate/Console/composer.json index 8183ff829ad19ac13df04a4bd663e8ff148178e1..ba5366e3b7eb6d017f6e4236123fa0222a68f0ac 100755 --- a/src/Illuminate/Console/composer.json +++ b/src/Illuminate/Console/composer.json @@ -14,11 +14,13 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "symfony/console": "^4.3.4", - "symfony/process": "^4.3.4" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "symfony/console": "^5.4", + "symfony/process": "^5.4" }, "autoload": { "psr-4": { @@ -27,16 +29,16 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "dragonmantank/cron-expression": "Required to use scheduler (^2.3.1).", - "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^6.3.1|^7.0.1).", - "illuminate/bus": "Required to use the scheduled job dispatcher (^6.0)", - "illuminate/container": "Required to use the scheduler (^6.0)", - "illuminate/filesystem": "Required to use the generator command (^6.0)", - "illuminate/queue": "Required to use closures for scheduled jobs (^6.0)" + "dragonmantank/cron-expression": "Required to use scheduler (^3.0.2).", + "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^6.5.5|^7.0.1).", + "illuminate/bus": "Required to use the scheduled job dispatcher (^8.0).", + "illuminate/container": "Required to use the scheduler (^8.0).", + "illuminate/filesystem": "Required to use the generator command (^8.0).", + "illuminate/queue": "Required to use closures for scheduled jobs (^8.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Container/BoundMethod.php b/src/Illuminate/Container/BoundMethod.php index c617bf79795ff70a0de46bc30670a121830124f2..5c96c973e0c831e2b54c86903c2df67ec49fae6a 100644 --- a/src/Illuminate/Container/BoundMethod.php +++ b/src/Illuminate/Container/BoundMethod.php @@ -156,6 +156,8 @@ class BoundMethod * @param array $parameters * @param array $dependencies * @return void + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ protected static function addDependencyForCallParameter($container, $parameter, array &$parameters, &$dependencies) @@ -170,7 +172,15 @@ class BoundMethod unset($parameters[$className]); } else { - $dependencies[] = $container->make($className); + if ($parameter->isVariadic()) { + $variadicDependencies = $container->make($className); + + $dependencies = array_merge($dependencies, is_array($variadicDependencies) + ? $variadicDependencies + : [$variadicDependencies]); + } else { + $dependencies[] = $container->make($className); + } } } elseif ($parameter->isDefaultValueAvailable()) { $dependencies[] = $parameter->getDefaultValue(); diff --git a/src/Illuminate/Container/Container.php b/src/Illuminate/Container/Container.php index c0e2082b360cd03417671e9e1dfcb92c8ad42e9b..e6cd346fede8c4496657b2238116c44e55e96c8e 100755 --- a/src/Illuminate/Container/Container.php +++ b/src/Illuminate/Container/Container.php @@ -6,11 +6,13 @@ use ArrayAccess; use Closure; use Exception; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Container\CircularDependencyException; use Illuminate\Contracts\Container\Container as ContainerContract; use LogicException; use ReflectionClass; use ReflectionException; use ReflectionParameter; +use TypeError; class Container implements ArrayAccess, ContainerContract { @@ -49,6 +51,13 @@ class Container implements ArrayAccess, ContainerContract */ protected $instances = []; + /** + * The container's scoped instances. + * + * @var array + */ + protected $scopedInstances = []; + /** * The registered type aliases. * @@ -105,6 +114,13 @@ class Container implements ArrayAccess, ContainerContract */ protected $reboundCallbacks = []; + /** + * All of the global before resolving callbacks. + * + * @var \Closure[] + */ + protected $globalBeforeResolvingCallbacks = []; + /** * All of the global resolving callbacks. * @@ -119,6 +135,13 @@ class Container implements ArrayAccess, ContainerContract */ protected $globalAfterResolvingCallbacks = []; + /** + * All of the before resolving callbacks by class type. + * + * @var array[] + */ + protected $beforeResolvingCallbacks = []; + /** * All of the resolving callbacks by class type. * @@ -164,7 +187,9 @@ class Container implements ArrayAccess, ContainerContract } /** - * {@inheritdoc} + * {@inheritdoc} + * + * @return bool */ public function has($id) { @@ -218,6 +243,8 @@ class Container implements ArrayAccess, ContainerContract * @param \Closure|string|null $concrete * @param bool $shared * @return void + * + * @throws \TypeError */ public function bind($abstract, $concrete = null, $shared = false) { @@ -234,6 +261,10 @@ class Container implements ArrayAccess, ContainerContract // bound into this container to the abstract type and we will just wrap it // up inside its own Closure to give us more convenience when extending. if (! $concrete instanceof Closure) { + if (! is_string($concrete)) { + throw new TypeError(self::class.'::bind(): Argument #2 ($concrete) must be of type Closure|string|null'); + } + $concrete = $this->getClosure($abstract, $concrete); } @@ -371,6 +402,36 @@ class Container implements ArrayAccess, ContainerContract } } + /** + * Register a scoped binding in the container. + * + * @param string $abstract + * @param \Closure|string|null $concrete + * @return void + */ + public function scoped($abstract, $concrete = null) + { + $this->scopedInstances[] = $abstract; + + $this->singleton($abstract, $concrete); + } + + /** + * Register a scoped binding if it hasn't already been registered. + * + * @param string $abstract + * @param \Closure|string|null $concrete + * @return void + */ + public function scopedIf($abstract, $concrete = null) + { + if (! $this->bound($abstract)) { + $this->scopedInstances[] = $abstract; + + $this->singleton($abstract, $concrete); + } + } + /** * "Extend" an abstract type in the container. * @@ -581,9 +642,11 @@ class Container implements ArrayAccess, ContainerContract * Call the given Closure / class@method and inject its dependencies. * * @param callable|string $callback - * @param array $parameters + * @param array<string, mixed> $parameters * @param string|null $defaultMethod * @return mixed + * + * @throws \InvalidArgumentException */ public function call($callback, array $parameters = [], $defaultMethod = null) { @@ -606,9 +669,11 @@ class Container implements ArrayAccess, ContainerContract /** * An alias function name for make(). * - * @param string $abstract + * @param string|callable $abstract * @param array $parameters * @return mixed + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ public function makeWith($abstract, array $parameters = []) { @@ -618,7 +683,7 @@ class Container implements ArrayAccess, ContainerContract /** * Resolve the given type from the container. * - * @param string $abstract + * @param string|callable $abstract * @param array $parameters * @return mixed * @@ -630,14 +695,16 @@ class Container implements ArrayAccess, ContainerContract } /** - * {@inheritdoc} + * {@inheritdoc} + * + * @return mixed */ public function get($id) { try { return $this->resolve($id); } catch (Exception $e) { - if ($this->has($id)) { + if ($this->has($id) || $e instanceof CircularDependencyException) { throw $e; } @@ -648,20 +715,28 @@ class Container implements ArrayAccess, ContainerContract /** * Resolve the given type from the container. * - * @param string $abstract + * @param string|callable $abstract * @param array $parameters * @param bool $raiseEvents * @return mixed * * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Illuminate\Contracts\Container\CircularDependencyException */ protected function resolve($abstract, $parameters = [], $raiseEvents = true) { $abstract = $this->getAlias($abstract); - $needsContextualBuild = ! empty($parameters) || ! is_null( - $this->getContextualConcrete($abstract) - ); + // First we'll fire any event handlers which handle the "before" resolving of + // specific types. This gives some hooks the chance to add various extends + // calls to change the resolution of objects that they're interested in. + if ($raiseEvents) { + $this->fireBeforeResolvingCallbacks($abstract, $parameters); + } + + $concrete = $this->getContextualConcrete($abstract); + + $needsContextualBuild = ! empty($parameters) || ! is_null($concrete); // If an instance of the type is currently being managed as a singleton we'll // just return an existing instance instead of instantiating new instances @@ -672,7 +747,9 @@ class Container implements ArrayAccess, ContainerContract $this->with[] = $parameters; - $concrete = $this->getConcrete($abstract); + if (is_null($concrete)) { + $concrete = $this->getConcrete($abstract); + } // We're ready to instantiate an instance of the concrete type registered for // the binding. This will instantiate the types, as well as resolve any of @@ -714,15 +791,11 @@ class Container implements ArrayAccess, ContainerContract /** * Get the concrete type for a given abstract. * - * @param string $abstract + * @param string|callable $abstract * @return mixed */ protected function getConcrete($abstract) { - if (! is_null($concrete = $this->getContextualConcrete($abstract))) { - return $concrete; - } - // If we don't have a registered resolver or concrete for the type, we'll just // assume each type is a concrete name and will attempt to resolve it as is // since the container should be able to resolve concretes automatically. @@ -736,8 +809,8 @@ class Container implements ArrayAccess, ContainerContract /** * Get the contextual concrete binding for the given abstract. * - * @param string $abstract - * @return \Closure|string|null + * @param string|callable $abstract + * @return \Closure|string|array|null */ protected function getContextualConcrete($abstract) { @@ -762,7 +835,7 @@ class Container implements ArrayAccess, ContainerContract /** * Find the concrete binding for the given abstract in the contextual binding array. * - * @param string $abstract + * @param string|callable $abstract * @return \Closure|string|null */ protected function findInContextualBindings($abstract) @@ -785,10 +858,11 @@ class Container implements ArrayAccess, ContainerContract /** * Instantiate a concrete instance of the given type. * - * @param string $concrete + * @param \Closure|string $concrete * @return mixed * * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Illuminate\Contracts\Container\CircularDependencyException */ public function build($concrete) { @@ -856,7 +930,7 @@ class Container implements ArrayAccess, ContainerContract $results = []; foreach ($dependencies as $dependency) { - // If this dependency has a override for this particular build we will use + // If the dependency has an override for this particular build we will use // that instead as the value. Otherwise, we will continue with this run // of resolutions and let reflection attempt to determine the result. if ($this->hasParameterOverride($dependency)) { @@ -868,9 +942,15 @@ class Container implements ArrayAccess, ContainerContract // If the class is null, it means the dependency is a string or some other // primitive type which we can not resolve since it is not a class and // we will just bomb out with an error since we have no-where to go. - $results[] = is_null(Util::getParameterClassName($dependency)) + $result = is_null(Util::getParameterClassName($dependency)) ? $this->resolvePrimitive($dependency) : $this->resolveClass($dependency); + + if ($dependency->isVariadic()) { + $results = array_merge($results, $result); + } else { + $results[] = $result; + } } return $results; @@ -942,21 +1022,52 @@ class Container implements ArrayAccess, ContainerContract protected function resolveClass(ReflectionParameter $parameter) { try { - return $this->make(Util::getParameterClassName($parameter)); + return $parameter->isVariadic() + ? $this->resolveVariadicClass($parameter) + : $this->make(Util::getParameterClassName($parameter)); } // If we can not resolve the class instance, we will check to see if the value // is optional, and if it is we will return the optional parameter value as // the value of the dependency, similarly to how we do this with scalars. catch (BindingResolutionException $e) { - if ($parameter->isOptional()) { + if ($parameter->isDefaultValueAvailable()) { + array_pop($this->with); + return $parameter->getDefaultValue(); } + if ($parameter->isVariadic()) { + array_pop($this->with); + + return []; + } + throw $e; } } + /** + * Resolve a class based variadic dependency from the container. + * + * @param \ReflectionParameter $parameter + * @return mixed + */ + protected function resolveVariadicClass(ReflectionParameter $parameter) + { + $className = Util::getParameterClassName($parameter); + + $abstract = $this->getAlias($className); + + if (! is_array($concrete = $this->getContextualConcrete($abstract))) { + return $this->make($className); + } + + return array_map(function ($abstract) { + return $this->resolve($abstract); + }, $concrete); + } + /** * Throw an exception that the concrete is not instantiable. * @@ -993,6 +1104,26 @@ class Container implements ArrayAccess, ContainerContract throw new BindingResolutionException($message); } + /** + * Register a new before resolving callback for all types. + * + * @param \Closure|string $abstract + * @param \Closure|null $callback + * @return void + */ + public function beforeResolving($abstract, Closure $callback = null) + { + if (is_string($abstract)) { + $abstract = $this->getAlias($abstract); + } + + if ($abstract instanceof Closure && is_null($callback)) { + $this->globalBeforeResolvingCallbacks[] = $abstract; + } else { + $this->beforeResolvingCallbacks[$abstract][] = $callback; + } + } + /** * Register a new resolving callback. * @@ -1033,6 +1164,39 @@ class Container implements ArrayAccess, ContainerContract } } + /** + * Fire all of the before resolving callbacks. + * + * @param string $abstract + * @param array $parameters + * @return void + */ + protected function fireBeforeResolvingCallbacks($abstract, $parameters = []) + { + $this->fireBeforeCallbackArray($abstract, $parameters, $this->globalBeforeResolvingCallbacks); + + foreach ($this->beforeResolvingCallbacks as $type => $callbacks) { + if ($type === $abstract || is_subclass_of($abstract, $type)) { + $this->fireBeforeCallbackArray($abstract, $parameters, $callbacks); + } + } + } + + /** + * Fire an array of callbacks with an object. + * + * @param string $abstract + * @param array $parameters + * @param array $callbacks + * @return void + */ + protected function fireBeforeCallbackArray($abstract, $parameters, array $callbacks) + { + foreach ($callbacks as $callback) { + $callback($abstract, $parameters, $this); + } + } + /** * Fire all of the resolving callbacks. * @@ -1073,7 +1237,6 @@ class Container implements ArrayAccess, ContainerContract * @param string $abstract * @param object $object * @param array $callbacksPerType - * * @return array */ protected function getCallbacksForType($abstract, $object, array $callbacksPerType) @@ -1121,11 +1284,9 @@ class Container implements ArrayAccess, ContainerContract */ public function getAlias($abstract) { - if (! isset($this->aliases[$abstract])) { - return $abstract; - } - - return $this->getAlias($this->aliases[$abstract]); + return isset($this->aliases[$abstract]) + ? $this->getAlias($this->aliases[$abstract]) + : $abstract; } /** @@ -1136,9 +1297,7 @@ class Container implements ArrayAccess, ContainerContract */ protected function getExtenders($abstract) { - $abstract = $this->getAlias($abstract); - - return $this->extenders[$abstract] ?? []; + return $this->extenders[$this->getAlias($abstract)] ?? []; } /** @@ -1184,6 +1343,18 @@ class Container implements ArrayAccess, ContainerContract $this->instances = []; } + /** + * Clear all of the scoped instances from the container. + * + * @return void + */ + public function forgetScopedInstances() + { + foreach ($this->scopedInstances as $scoped) { + unset($this->instances[$scoped]); + } + } + /** * Flush the container of all bindings and resolved instances. * @@ -1196,6 +1367,7 @@ class Container implements ArrayAccess, ContainerContract $this->bindings = []; $this->instances = []; $this->abstractAliases = []; + $this->scopedInstances = []; } /** @@ -1229,6 +1401,7 @@ class Container implements ArrayAccess, ContainerContract * @param string $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return $this->bound($key); @@ -1240,6 +1413,7 @@ class Container implements ArrayAccess, ContainerContract * @param string $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->make($key); @@ -1252,6 +1426,7 @@ class Container implements ArrayAccess, ContainerContract * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->bind($key, $value instanceof Closure ? $value : function () use ($value) { @@ -1265,6 +1440,7 @@ class Container implements ArrayAccess, ContainerContract * @param string $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->bindings[$key], $this->instances[$key], $this->resolved[$key]); diff --git a/src/Illuminate/Container/ContextualBindingBuilder.php b/src/Illuminate/Container/ContextualBindingBuilder.php index a52db5d5047c5478fd2db197cb5add4f23a70138..1d15dcd3da6a0e0dfdc7eda932c2a934b181828b 100644 --- a/src/Illuminate/Container/ContextualBindingBuilder.php +++ b/src/Illuminate/Container/ContextualBindingBuilder.php @@ -57,7 +57,7 @@ class ContextualBindingBuilder implements ContextualBindingBuilderContract /** * Define the implementation for the contextual binding. * - * @param \Closure|string $implementation + * @param \Closure|string|array $implementation * @return void */ public function give($implementation) @@ -66,4 +66,33 @@ class ContextualBindingBuilder implements ContextualBindingBuilderContract $this->container->addContextualBinding($concrete, $this->needs, $implementation); } } + + /** + * Define tagged services to be used as the implementation for the contextual binding. + * + * @param string $tag + * @return void + */ + public function giveTagged($tag) + { + $this->give(function ($container) use ($tag) { + $taggedServices = $container->tagged($tag); + + return is_array($taggedServices) ? $taggedServices : iterator_to_array($taggedServices); + }); + } + + /** + * Specify the configuration item to bind as a primitive. + * + * @param string $key + * @param ?string $default + * @return void + */ + public function giveConfig($key, $default = null) + { + $this->give(function ($container) use ($key, $default) { + return $container->get('config')->get($key, $default); + }); + } } diff --git a/src/Illuminate/Container/RewindableGenerator.php b/src/Illuminate/Container/RewindableGenerator.php index 675527d87eb41f918207d434bd8e5e8dcc05ce47..4ee7bb20c667d1489c3082b6b3aa2fdf707b2864 100644 --- a/src/Illuminate/Container/RewindableGenerator.php +++ b/src/Illuminate/Container/RewindableGenerator.php @@ -39,6 +39,7 @@ class RewindableGenerator implements Countable, IteratorAggregate * * @return mixed */ + #[\ReturnTypeWillChange] public function getIterator() { return ($this->generator)(); @@ -49,6 +50,7 @@ class RewindableGenerator implements Countable, IteratorAggregate * * @return int */ + #[\ReturnTypeWillChange] public function count() { if (is_callable($count = $this->count)) { diff --git a/src/Illuminate/Container/Util.php b/src/Illuminate/Container/Util.php index 0b4bb12834671fb72bac8cc6a78d1220319b06db..8f7e9171d6ef1024daaed986af38a2cc9282e09d 100644 --- a/src/Illuminate/Container/Util.php +++ b/src/Illuminate/Container/Util.php @@ -5,6 +5,9 @@ namespace Illuminate\Container; use Closure; use ReflectionNamedType; +/** + * @internal + */ class Util { /** @@ -50,7 +53,7 @@ class Util $type = $parameter->getType(); if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { - return; + return null; } $name = $type->getName(); diff --git a/src/Illuminate/Container/composer.json b/src/Illuminate/Container/composer.json index bb1a7e397abfb3c4713112f02e1969bcc8c180d1..cf93160996bfafaed44e8a22e57420b7e7a19c04 100755 --- a/src/Illuminate/Container/composer.json +++ b/src/Illuminate/Container/composer.json @@ -14,10 +14,13 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", + "php": "^7.3|^8.0", + "illuminate/contracts": "^8.0", "psr/container": "^1.0" }, + "provide": { + "psr/container-implementation": "1.0" + }, "autoload": { "psr-4": { "Illuminate\\Container\\": "" @@ -25,7 +28,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Contracts/Auth/Access/Authorizable.php b/src/Illuminate/Contracts/Auth/Access/Authorizable.php index 2f9657c574923a915f03b1f46ee165774e1207c7..cedeb6ea3440c5deefe45951f989254296beec11 100644 --- a/src/Illuminate/Contracts/Auth/Access/Authorizable.php +++ b/src/Illuminate/Contracts/Auth/Access/Authorizable.php @@ -7,9 +7,9 @@ interface Authorizable /** * Determine if the entity has a given ability. * - * @param string $ability + * @param iterable|string $abilities * @param array|mixed $arguments * @return bool */ - public function can($ability, $arguments = []); + public function can($abilities, $arguments = []); } diff --git a/src/Illuminate/Contracts/Auth/Middleware/AuthenticatesRequests.php b/src/Illuminate/Contracts/Auth/Middleware/AuthenticatesRequests.php new file mode 100644 index 0000000000000000000000000000000000000000..b782761f9c3a5f22f5155aa659998d8d50fbc319 --- /dev/null +++ b/src/Illuminate/Contracts/Auth/Middleware/AuthenticatesRequests.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Contracts\Auth\Middleware; + +interface AuthenticatesRequests +{ + // +} diff --git a/src/Illuminate/Contracts/Auth/PasswordBroker.php b/src/Illuminate/Contracts/Auth/PasswordBroker.php index 6bca9f36be0508ad97b88a99fa4bcaa81a99cc76..bbbe9b508688965f63c6e29d88e189b13c1576f0 100644 --- a/src/Illuminate/Contracts/Auth/PasswordBroker.php +++ b/src/Illuminate/Contracts/Auth/PasswordBroker.php @@ -45,9 +45,10 @@ interface PasswordBroker * Send a password reset link to a user. * * @param array $credentials + * @param \Closure|null $callback * @return string */ - public function sendResetLink(array $credentials); + public function sendResetLink(array $credentials, Closure $callback = null); /** * Reset the password for the given token. diff --git a/src/Illuminate/Contracts/Auth/StatefulGuard.php b/src/Illuminate/Contracts/Auth/StatefulGuard.php index 961d2f6f56b67ad76eedff63cef3f6fdefa6eac0..faf1497d5f84671ba71905f227a3f163fb94d7fa 100644 --- a/src/Illuminate/Contracts/Auth/StatefulGuard.php +++ b/src/Illuminate/Contracts/Auth/StatefulGuard.php @@ -35,7 +35,7 @@ interface StatefulGuard extends Guard * * @param mixed $id * @param bool $remember - * @return \Illuminate\Contracts\Auth\Authenticatable + * @return \Illuminate\Contracts\Auth\Authenticatable|bool */ public function loginUsingId($id, $remember = false); @@ -43,7 +43,7 @@ interface StatefulGuard extends Guard * Log the given user ID into the application without sessions or cookies. * * @param mixed $id - * @return bool + * @return \Illuminate\Contracts\Auth\Authenticatable|bool */ public function onceUsingId($id); diff --git a/src/Illuminate/Contracts/Broadcasting/Factory.php b/src/Illuminate/Contracts/Broadcasting/Factory.php index 384c2d02dc692c25d012544df775c226d77237b4..1a4f48f9c228c70edf55ff8f3063b64d83c5538d 100644 --- a/src/Illuminate/Contracts/Broadcasting/Factory.php +++ b/src/Illuminate/Contracts/Broadcasting/Factory.php @@ -8,7 +8,7 @@ interface Factory * Get a broadcaster implementation by name. * * @param string|null $name - * @return void + * @return \Illuminate\Contracts\Broadcasting\Broadcaster */ public function connection($name = null); } diff --git a/src/Illuminate/Contracts/Broadcasting/HasBroadcastChannel.php b/src/Illuminate/Contracts/Broadcasting/HasBroadcastChannel.php new file mode 100644 index 0000000000000000000000000000000000000000..3b2c4018e3892ae78088fde6b3317ae4bdd09c4d --- /dev/null +++ b/src/Illuminate/Contracts/Broadcasting/HasBroadcastChannel.php @@ -0,0 +1,20 @@ +<?php + +namespace Illuminate\Contracts\Broadcasting; + +interface HasBroadcastChannel +{ + /** + * Get the broadcast channel route definition that is associated with the given entity. + * + * @return string + */ + public function broadcastChannelRoute(); + + /** + * Get the broadcast channel name that is associated with the given entity. + * + * @return string + */ + public function broadcastChannel(); +} diff --git a/src/Illuminate/Contracts/Broadcasting/ShouldBroadcast.php b/src/Illuminate/Contracts/Broadcasting/ShouldBroadcast.php index a4802fe40ebb820c13f9f713f2bd3fc815358e44..3dc4662ce3db4e0ec4af0b806e9ff7f244230a09 100644 --- a/src/Illuminate/Contracts/Broadcasting/ShouldBroadcast.php +++ b/src/Illuminate/Contracts/Broadcasting/ShouldBroadcast.php @@ -7,7 +7,7 @@ interface ShouldBroadcast /** * Get the channels the event should broadcast on. * - * @return \Illuminate\Broadcasting\Channel|\Illuminate\Broadcasting\Channel[] + * @return \Illuminate\Broadcasting\Channel|\Illuminate\Broadcasting\Channel[]|string[]|string */ public function broadcastOn(); } diff --git a/src/Illuminate/Contracts/Bus/Dispatcher.php b/src/Illuminate/Contracts/Bus/Dispatcher.php index 12ed2226b416f36e905300a8f96a5894d1aa0aae..5cbbd92954f6b750b448ba764ec5f082bcbd563d 100644 --- a/src/Illuminate/Contracts/Bus/Dispatcher.php +++ b/src/Illuminate/Contracts/Bus/Dispatcher.php @@ -12,6 +12,17 @@ interface Dispatcher */ public function dispatch($command); + /** + * Dispatch a command to its appropriate handler in the current process. + * + * Queueable jobs will be dispatched to the "sync" queue. + * + * @param mixed $command + * @param mixed $handler + * @return mixed + */ + public function dispatchSync($command, $handler = null); + /** * Dispatch a command to its appropriate handler in the current process. * diff --git a/src/Illuminate/Contracts/Bus/QueueingDispatcher.php b/src/Illuminate/Contracts/Bus/QueueingDispatcher.php index e99f7ebc29cbae87cb7be07ff8653b84e7c1b3ed..ff84e2752faf0f2e222c32009ef10ca640acb698 100644 --- a/src/Illuminate/Contracts/Bus/QueueingDispatcher.php +++ b/src/Illuminate/Contracts/Bus/QueueingDispatcher.php @@ -4,6 +4,22 @@ namespace Illuminate\Contracts\Bus; interface QueueingDispatcher extends Dispatcher { + /** + * Attempt to find the batch with the given ID. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function findBatch(string $batchId); + + /** + * Create a new batch of queueable jobs. + * + * @param \Illuminate\Support\Collection|array $jobs + * @return \Illuminate\Bus\PendingBatch + */ + public function batch($jobs); + /** * Dispatch a command to its appropriate handler behind a queue. * diff --git a/src/Illuminate/Contracts/Cache/Lock.php b/src/Illuminate/Contracts/Cache/Lock.php index 7f01b1be3f33c4520e9fcfbda6c7df3a48e3cda4..03f633a07a21c1148a6ec445ea3faf7779e1f5d7 100644 --- a/src/Illuminate/Contracts/Cache/Lock.php +++ b/src/Illuminate/Contracts/Cache/Lock.php @@ -17,7 +17,7 @@ interface Lock * * @param int $seconds * @param callable|null $callback - * @return bool + * @return mixed */ public function block($seconds, $callback = null); diff --git a/src/Illuminate/Contracts/Console/Kernel.php b/src/Illuminate/Contracts/Console/Kernel.php index a7423af3a6c1af032173eeee120d70d314ab3d56..842f5a6a954715033bfa0975a2e65316bca8024f 100644 --- a/src/Illuminate/Contracts/Console/Kernel.php +++ b/src/Illuminate/Contracts/Console/Kernel.php @@ -4,6 +4,13 @@ namespace Illuminate\Contracts\Console; interface Kernel { + /** + * Bootstrap the application for artisan commands. + * + * @return void + */ + public function bootstrap(); + /** * Handle an incoming console command. * diff --git a/src/Illuminate/Contracts/Container/CircularDependencyException.php b/src/Illuminate/Contracts/Container/CircularDependencyException.php new file mode 100644 index 0000000000000000000000000000000000000000..6c90381cc0cda6929d9a17667d643f8c02634636 --- /dev/null +++ b/src/Illuminate/Contracts/Container/CircularDependencyException.php @@ -0,0 +1,11 @@ +<?php + +namespace Illuminate\Contracts\Container; + +use Exception; +use Psr\Container\ContainerExceptionInterface; + +class CircularDependencyException extends Exception implements ContainerExceptionInterface +{ + // +} diff --git a/src/Illuminate/Contracts/Container/ContextualBindingBuilder.php b/src/Illuminate/Contracts/Container/ContextualBindingBuilder.php index e2ee14721d12b9cebc1efafaae1864532c3ac99b..149e7b28c9214d5161e51a0dcd80f669a3ed68aa 100644 --- a/src/Illuminate/Contracts/Container/ContextualBindingBuilder.php +++ b/src/Illuminate/Contracts/Container/ContextualBindingBuilder.php @@ -15,8 +15,16 @@ interface ContextualBindingBuilder /** * Define the implementation for the contextual binding. * - * @param \Closure|string $implementation + * @param \Closure|string|array $implementation * @return void */ public function give($implementation); + + /** + * Define tagged services to be used as the implementation for the contextual binding. + * + * @param string $tag + * @return void + */ + public function giveTagged($tag); } diff --git a/src/Illuminate/Contracts/Database/Eloquent/Castable.php b/src/Illuminate/Contracts/Database/Eloquent/Castable.php new file mode 100644 index 0000000000000000000000000000000000000000..911b1cf86af2a29f619bb8e84c2c3561018cab51 --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/Castable.php @@ -0,0 +1,15 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface Castable +{ + /** + * Get the name of the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return string + * @return string|\Illuminate\Contracts\Database\Eloquent\CastsAttributes|\Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes + */ + public static function castUsing(array $arguments); +} diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..808d005f5c1db15544ae93afb00dbe22a96a9f17 --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php @@ -0,0 +1,28 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface CastsAttributes +{ + /** + * Transform the attribute from the underlying model values. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function get($model, string $key, $value, array $attributes); + + /** + * Transform the attribute to its underlying model values. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function set($model, string $key, $value, array $attributes); +} diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..4c7801b583f1f018b0294c57418354d9c07c0d27 --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php @@ -0,0 +1,17 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface CastsInboundAttributes +{ + /** + * Transform the attribute to its underlying model values. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function set($model, string $key, $value, array $attributes); +} diff --git a/src/Illuminate/Contracts/Database/Eloquent/DeviatesCastableAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/DeviatesCastableAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..48ba73abc369c1a41539fc1e9e53a1ef5e1739ff --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/DeviatesCastableAttributes.php @@ -0,0 +1,28 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface DeviatesCastableAttributes +{ + /** + * Increment the attribute. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function increment($model, string $key, $value, array $attributes); + + /** + * Decrement the attribute. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function decrement($model, string $key, $value, array $attributes); +} diff --git a/src/Illuminate/Contracts/Database/Eloquent/SerializesCastableAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/SerializesCastableAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..a89f91010fb3e54d8a2ee5001954bb9cac1a8b8a --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/SerializesCastableAttributes.php @@ -0,0 +1,17 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface SerializesCastableAttributes +{ + /** + * Serialize the attribute when converting the model to an array. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function serialize($model, string $key, $value, array $attributes); +} diff --git a/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php b/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php new file mode 100644 index 0000000000000000000000000000000000000000..c82125aa0c10de8b6bd13bf641a79a416bc86f4d --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Contracts\Database\Eloquent; + +interface SupportsPartialRelations +{ + /** + * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @param string|null $column + * @param string|\Closure|null $aggregate + * @param string $relation + * @return $this + */ + public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null); + + /** + * Determine whether the relationship is a one-of-many relationship. + * + * @return bool + */ + public function isOneOfMany(); + + /** + * Get the one of many inner join subselect query builder instance. + * + * @return \Illuminate\Database\Eloquent\Builder|void + */ + public function getOneOfManySubQuery(); +} diff --git a/src/Illuminate/Contracts/Debug/ExceptionHandler.php b/src/Illuminate/Contracts/Debug/ExceptionHandler.php index 334a786905b0fd3c8f1025ff504e7ae462ad42a4..54381a179af20c8e7397f1080b7ce0411d6a3fbe 100644 --- a/src/Illuminate/Contracts/Debug/ExceptionHandler.php +++ b/src/Illuminate/Contracts/Debug/ExceptionHandler.php @@ -2,45 +2,45 @@ namespace Illuminate\Contracts\Debug; -use Exception; +use Throwable; interface ExceptionHandler { /** * Report or log an exception. * - * @param \Exception $e + * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - public function report(Exception $e); + public function report(Throwable $e); /** * Determine if the exception should be reported. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - public function shouldReport(Exception $e); + public function shouldReport(Throwable $e); /** * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response * - * @throws \Exception + * @throws \Throwable */ - public function render($request, Exception $e); + public function render($request, Throwable $e); /** * Render an exception to the console. * * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param \Exception $e + * @param \Throwable $e * @return void */ - public function renderForConsole($output, Exception $e); + public function renderForConsole($output, Throwable $e); } diff --git a/src/Illuminate/Contracts/Encryption/StringEncrypter.php b/src/Illuminate/Contracts/Encryption/StringEncrypter.php new file mode 100644 index 0000000000000000000000000000000000000000..1e6938c29a16782e647d0c97e22a53a07937b4a5 --- /dev/null +++ b/src/Illuminate/Contracts/Encryption/StringEncrypter.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Contracts\Encryption; + +interface StringEncrypter +{ + /** + * Encrypt a string without serialization. + * + * @param string $value + * @return string + * + * @throws \Illuminate\Contracts\Encryption\EncryptException + */ + public function encryptString($value); + + /** + * Decrypt the given string without unserialization. + * + * @param string $payload + * @return string + * + * @throws \Illuminate\Contracts\Encryption\DecryptException + */ + public function decryptString($payload); +} diff --git a/src/Illuminate/Contracts/Events/Dispatcher.php b/src/Illuminate/Contracts/Events/Dispatcher.php index 866080dac7f3671de69acbf040f9cacd93b5ca69..638610698e68f2114b44a1dbefa257fb69c0bed3 100644 --- a/src/Illuminate/Contracts/Events/Dispatcher.php +++ b/src/Illuminate/Contracts/Events/Dispatcher.php @@ -7,11 +7,11 @@ interface Dispatcher /** * Register an event listener with the dispatcher. * - * @param string|array $events - * @param \Closure|string $listener + * @param \Closure|string|array $events + * @param \Closure|string|array|null $listener * @return void */ - public function listen($events, $listener); + public function listen($events, $listener = null); /** * Determine if a given event has listeners. diff --git a/src/Illuminate/Contracts/Filesystem/LockTimeoutException.php b/src/Illuminate/Contracts/Filesystem/LockTimeoutException.php new file mode 100644 index 0000000000000000000000000000000000000000..f03f5c4e6c1de2561b710b5c3531363414b4e41d --- /dev/null +++ b/src/Illuminate/Contracts/Filesystem/LockTimeoutException.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Contracts\Filesystem; + +use Exception; + +class LockTimeoutException extends Exception +{ + // +} diff --git a/src/Illuminate/Contracts/Foundation/Application.php b/src/Illuminate/Contracts/Foundation/Application.php index c17adc6eed74920326199a80ba00448a3b95232c..74af47c751cbd1562f77dd8f51372cc834eea200 100644 --- a/src/Illuminate/Contracts/Foundation/Application.php +++ b/src/Illuminate/Contracts/Foundation/Application.php @@ -2,7 +2,6 @@ namespace Illuminate\Contracts\Foundation; -use Closure; use Illuminate\Contracts\Container\Container; interface Application extends Container @@ -25,7 +24,7 @@ interface Application extends Container /** * Get the path to the bootstrap directory. * - * @param string $path Optionally, a path to append to the bootstrap path + * @param string $path * @return string */ public function bootstrapPath($path = ''); @@ -33,7 +32,7 @@ interface Application extends Container /** * Get the path to the application configuration files. * - * @param string $path Optionally, a path to append to the config path + * @param string $path * @return string */ public function configPath($path = ''); @@ -41,18 +40,11 @@ interface Application extends Container /** * Get the path to the database directory. * - * @param string $path Optionally, a path to append to the database path + * @param string $path * @return string */ public function databasePath($path = ''); - /** - * Get the path to the environment file directory. - * - * @return string - */ - public function environmentPath(); - /** * Get the path to the resources directory. * @@ -161,63 +153,6 @@ interface Application extends Container */ public function bootstrapWith(array $bootstrappers); - /** - * Determine if the application configuration is cached. - * - * @return bool - */ - public function configurationIsCached(); - - /** - * Detect the application's current environment. - * - * @param \Closure $callback - * @return string - */ - public function detectEnvironment(Closure $callback); - - /** - * Get the environment file the application is using. - * - * @return string - */ - public function environmentFile(); - - /** - * Get the fully qualified path to the environment file. - * - * @return string - */ - public function environmentFilePath(); - - /** - * Get the path to the configuration cache file. - * - * @return string - */ - public function getCachedConfigPath(); - - /** - * Get the path to the cached services.php file. - * - * @return string - */ - public function getCachedServicesPath(); - - /** - * Get the path to the cached packages.php file. - * - * @return string - */ - public function getCachedPackagesPath(); - - /** - * Get the path to the routes cache file. - * - * @return string - */ - public function getCachedRoutesPath(); - /** * Get the current application locale. * @@ -256,21 +191,6 @@ interface Application extends Container */ public function loadDeferredProviders(); - /** - * Set the environment file to be loaded during bootstrapping. - * - * @param string $file - * @return $this - */ - public function loadEnvironmentFrom($file); - - /** - * Determine if the application routes are cached. - * - * @return bool - */ - public function routesAreCached(); - /** * Set the current application locale. * diff --git a/src/Illuminate/Contracts/Foundation/CachesConfiguration.php b/src/Illuminate/Contracts/Foundation/CachesConfiguration.php new file mode 100644 index 0000000000000000000000000000000000000000..08ebdaf7dfd5747f163561a6b17c5ed1569e9b7a --- /dev/null +++ b/src/Illuminate/Contracts/Foundation/CachesConfiguration.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Contracts\Foundation; + +interface CachesConfiguration +{ + /** + * Determine if the application configuration is cached. + * + * @return bool + */ + public function configurationIsCached(); + + /** + * Get the path to the configuration cache file. + * + * @return string + */ + public function getCachedConfigPath(); + + /** + * Get the path to the cached services.php file. + * + * @return string + */ + public function getCachedServicesPath(); +} diff --git a/src/Illuminate/Contracts/Foundation/CachesRoutes.php b/src/Illuminate/Contracts/Foundation/CachesRoutes.php new file mode 100644 index 0000000000000000000000000000000000000000..a5c3455eb5dd346a3370df035b5bbd6dc6698014 --- /dev/null +++ b/src/Illuminate/Contracts/Foundation/CachesRoutes.php @@ -0,0 +1,20 @@ +<?php + +namespace Illuminate\Contracts\Foundation; + +interface CachesRoutes +{ + /** + * Determine if the application routes are cached. + * + * @return bool + */ + public function routesAreCached(); + + /** + * Get the path to the routes cache file. + * + * @return string + */ + public function getCachedRoutesPath(); +} diff --git a/src/Illuminate/Contracts/Mail/Factory.php b/src/Illuminate/Contracts/Mail/Factory.php new file mode 100644 index 0000000000000000000000000000000000000000..fe45a2fd9cd885023bd6f0679a7c54cc2f967a1f --- /dev/null +++ b/src/Illuminate/Contracts/Mail/Factory.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Mail; + +interface Factory +{ + /** + * Get a mailer instance by name. + * + * @param string|null $name + * @return \Illuminate\Contracts\Mail\Mailer + */ + public function mailer($name = null); +} diff --git a/src/Illuminate/Contracts/Mail/Mailable.php b/src/Illuminate/Contracts/Mail/Mailable.php index 882b2167715b91b671521c12df5cf7b0575e47d2..bfdf4ef865609dcba46a8b298927bae811b7b4f8 100644 --- a/src/Illuminate/Contracts/Mail/Mailable.php +++ b/src/Illuminate/Contracts/Mail/Mailable.php @@ -9,10 +9,10 @@ interface Mailable /** * Send the message using the given mailer. * - * @param \Illuminate\Contracts\Mail\Mailer $mailer + * @param \Illuminate\Contracts\Mail\Factory|\Illuminate\Contracts\Mail\Mailer $mailer * @return void */ - public function send(Mailer $mailer); + public function send($mailer); /** * Queue the given message. @@ -30,4 +30,47 @@ interface Mailable * @return mixed */ public function later($delay, Queue $queue); + + /** + * Set the recipients of the message. + * + * @param object|array|string $address + * @param string|null $name + * @return self + */ + public function cc($address, $name = null); + + /** + * Set the recipients of the message. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function bcc($address, $name = null); + + /** + * Set the recipients of the message. + * + * @param object|array|string $address + * @param string|null $name + * @return $this + */ + public function to($address, $name = null); + + /** + * Set the locale of the message. + * + * @param string $locale + * @return $this + */ + public function locale($locale); + + /** + * Set the name of the mailer that should be used to send the message. + * + * @param string $mailer + * @return $this + */ + public function mailer($mailer); } diff --git a/src/Illuminate/Contracts/Pagination/CursorPaginator.php b/src/Illuminate/Contracts/Pagination/CursorPaginator.php new file mode 100644 index 0000000000000000000000000000000000000000..2d62d3a51e78d38705d6f005fc5b0558949d740f --- /dev/null +++ b/src/Illuminate/Contracts/Pagination/CursorPaginator.php @@ -0,0 +1,117 @@ +<?php + +namespace Illuminate\Contracts\Pagination; + +interface CursorPaginator +{ + /** + * Get the URL for a given cursor. + * + * @param \Illuminate\Pagination\Cursor|null $cursor + * @return string + */ + public function url($cursor); + + /** + * Add a set of query string values to the paginator. + * + * @param array|string|null $key + * @param string|null $value + * @return $this + */ + public function appends($key, $value = null); + + /** + * Get / set the URL fragment to be appended to URLs. + * + * @param string|null $fragment + * @return $this|string|null + */ + public function fragment($fragment = null); + + /** + * Get the URL for the previous page, or null. + * + * @return string|null + */ + public function previousPageUrl(); + + /** + * The URL for the next page, or null. + * + * @return string|null + */ + public function nextPageUrl(); + + /** + * Get all of the items being paginated. + * + * @return array + */ + public function items(); + + /** + * Get the "cursor" of the previous set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function previousCursor(); + + /** + * Get the "cursor" of the next set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function nextCursor(); + + /** + * Determine how many items are being shown per page. + * + * @return int + */ + public function perPage(); + + /** + * Get the current cursor being paginated. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function cursor(); + + /** + * Determine if there are enough items to split into multiple pages. + * + * @return bool + */ + public function hasPages(); + + /** + * Get the base path for paginator generated URLs. + * + * @return string|null + */ + public function path(); + + /** + * Determine if the list of items is empty or not. + * + * @return bool + */ + public function isEmpty(); + + /** + * Determine if the list of items is not empty. + * + * @return bool + */ + public function isNotEmpty(); + + /** + * Render the paginator using a given view. + * + * @param string|null $view + * @param array $data + * @return string + */ + public function render($view = null, $data = []); +} diff --git a/src/Illuminate/Contracts/Queue/ClearableQueue.php b/src/Illuminate/Contracts/Queue/ClearableQueue.php new file mode 100644 index 0000000000000000000000000000000000000000..427f61bf5ff7e19cd11dafda51dbe77afaf00762 --- /dev/null +++ b/src/Illuminate/Contracts/Queue/ClearableQueue.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Queue; + +interface ClearableQueue +{ + /** + * Delete all of the jobs from the queue. + * + * @param string $queue + * @return int + */ + public function clear($queue); +} diff --git a/src/Illuminate/Contracts/Queue/Job.php b/src/Illuminate/Contracts/Queue/Job.php index 490d44f9812676c5c85e20c35f547b01732147e1..c856215570b8b4e633d8a28722ed6604f83b8795 100644 --- a/src/Illuminate/Contracts/Queue/Job.php +++ b/src/Illuminate/Contracts/Queue/Job.php @@ -4,6 +4,13 @@ namespace Illuminate\Contracts\Queue; interface Job { + /** + * Get the UUID of the job. + * + * @return string|null + */ + public function uuid(); + /** * Get the job identifier. * @@ -99,6 +106,13 @@ interface Job */ public function maxTries(); + /** + * Get the maximum number of exceptions allowed, regardless of attempts. + * + * @return int|null + */ + public function maxExceptions(); + /** * Get the number of seconds the job can run. * @@ -111,7 +125,7 @@ interface Job * * @return int|null */ - public function timeoutAt(); + public function retryUntil(); /** * Get the name of the queued job class. diff --git a/src/Illuminate/Contracts/Queue/Queue.php b/src/Illuminate/Contracts/Queue/Queue.php index ba79f9f1f20af2388730d166a80752e72993e541..073b3c1d19c9d2448f5722a3bd98aee23e932cf5 100644 --- a/src/Illuminate/Contracts/Queue/Queue.php +++ b/src/Illuminate/Contracts/Queue/Queue.php @@ -77,7 +77,7 @@ interface Queue /** * Pop the next job off of the queue. * - * @param string $queue + * @param string|null $queue * @return \Illuminate\Contracts\Queue\Job|null */ public function pop($queue = null); diff --git a/src/Illuminate/Contracts/Queue/ShouldBeEncrypted.php b/src/Illuminate/Contracts/Queue/ShouldBeEncrypted.php new file mode 100644 index 0000000000000000000000000000000000000000..374df8998a0cf5babd17ddf70379e8eca269834c --- /dev/null +++ b/src/Illuminate/Contracts/Queue/ShouldBeEncrypted.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Contracts\Queue; + +interface ShouldBeEncrypted +{ + // +} diff --git a/src/Illuminate/Contracts/Queue/ShouldBeUnique.php b/src/Illuminate/Contracts/Queue/ShouldBeUnique.php new file mode 100644 index 0000000000000000000000000000000000000000..b216434134901e04eca7f1876124677d378079b2 --- /dev/null +++ b/src/Illuminate/Contracts/Queue/ShouldBeUnique.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Contracts\Queue; + +interface ShouldBeUnique +{ + // +} diff --git a/src/Illuminate/Contracts/Queue/ShouldBeUniqueUntilProcessing.php b/src/Illuminate/Contracts/Queue/ShouldBeUniqueUntilProcessing.php new file mode 100644 index 0000000000000000000000000000000000000000..510cab96f43a247b5afa4bd5181f74d012c7bb9e --- /dev/null +++ b/src/Illuminate/Contracts/Queue/ShouldBeUniqueUntilProcessing.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Contracts\Queue; + +interface ShouldBeUniqueUntilProcessing extends ShouldBeUnique +{ + // +} diff --git a/src/Illuminate/Contracts/Routing/Registrar.php b/src/Illuminate/Contracts/Routing/Registrar.php index 5b3790c589eb9b07b5af5d751a17e8766cf5b97b..57e327231dcdc09b27013a4b9745901c7000d567 100644 --- a/src/Illuminate/Contracts/Routing/Registrar.php +++ b/src/Illuminate/Contracts/Routing/Registrar.php @@ -8,7 +8,7 @@ interface Registrar * Register a new GET route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function get($uri, $action); @@ -17,7 +17,7 @@ interface Registrar * Register a new POST route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function post($uri, $action); @@ -26,7 +26,7 @@ interface Registrar * Register a new PUT route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function put($uri, $action); @@ -35,7 +35,7 @@ interface Registrar * Register a new DELETE route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function delete($uri, $action); @@ -44,7 +44,7 @@ interface Registrar * Register a new PATCH route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function patch($uri, $action); @@ -53,7 +53,7 @@ interface Registrar * Register a new OPTIONS route with the router. * * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function options($uri, $action); @@ -63,7 +63,7 @@ interface Registrar * * @param array|string $methods * @param string $uri - * @param \Closure|array|string|callable $action + * @param array|string|callable $action * @return \Illuminate\Routing\Route */ public function match($methods, $uri, $action); diff --git a/src/Illuminate/Contracts/Routing/ResponseFactory.php b/src/Illuminate/Contracts/Routing/ResponseFactory.php index 5b8f22c0f7afd6fe7417171d091b10e94e85d0ad..86c16cab8f165cc2a128e4b40f0240bd3b144f3e 100644 --- a/src/Illuminate/Contracts/Routing/ResponseFactory.php +++ b/src/Illuminate/Contracts/Routing/ResponseFactory.php @@ -7,7 +7,7 @@ interface ResponseFactory /** * Create a new response instance. * - * @param string $content + * @param array|string $content * @param int $status * @param array $headers * @return \Illuminate\Http\Response @@ -37,7 +37,7 @@ interface ResponseFactory /** * Create a new JSON response instance. * - * @param string|array|object $data + * @param mixed $data * @param int $status * @param array $headers * @param int $options @@ -49,7 +49,7 @@ interface ResponseFactory * Create a new JSONP response instance. * * @param string $callback - * @param string|array|object $data + * @param mixed $data * @param int $status * @param array $headers * @param int $options @@ -113,7 +113,7 @@ interface ResponseFactory * Create a new redirect response to a named route. * * @param string $route - * @param array $parameters + * @param mixed $parameters * @param int $status * @param array $headers * @return \Illuminate\Http\RedirectResponse @@ -124,7 +124,7 @@ interface ResponseFactory * Create a new redirect response to a controller action. * * @param string $action - * @param array $parameters + * @param mixed $parameters * @param int $status * @param array $headers * @return \Illuminate\Http\RedirectResponse diff --git a/src/Illuminate/Contracts/Routing/UrlRoutable.php b/src/Illuminate/Contracts/Routing/UrlRoutable.php index d1dd94cc700aa0d0d5fd7cfce37b749854e2f101..48c3d723dcc90b99098f56b981284b391f159561 100644 --- a/src/Illuminate/Contracts/Routing/UrlRoutable.php +++ b/src/Illuminate/Contracts/Routing/UrlRoutable.php @@ -22,7 +22,18 @@ interface UrlRoutable * Retrieve the model for a bound value. * * @param mixed $value + * @param string|null $field * @return \Illuminate\Database\Eloquent\Model|null */ - public function resolveRouteBinding($value); + public function resolveRouteBinding($value, $field = null); + + /** + * Retrieve the child model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveChildRouteBinding($childType, $value, $field); } diff --git a/src/Illuminate/Contracts/Session/Session.php b/src/Illuminate/Contracts/Session/Session.php index 6a6e0a1547024ae3211b31c40cd3edfbf14eb272..1bf025a096c8ae6a83b2844215cf51126c8a0b30 100644 --- a/src/Illuminate/Contracts/Session/Session.php +++ b/src/Illuminate/Contracts/Session/Session.php @@ -11,6 +11,14 @@ interface Session */ public function getName(); + /** + * Set the name of the session. + * + * @param string $name + * @return void + */ + public function setName($name); + /** * Get the current session ID. * @@ -72,6 +80,15 @@ interface Session */ public function get($key, $default = null); + /** + * Get the value of a given key and then forget it. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function pull($key, $default = null); + /** * Put a key / value pair or array of key / value pairs in the session. * @@ -88,6 +105,13 @@ interface Session */ public function token(); + /** + * Regenerate the CSRF token value. + * + * @return void + */ + public function regenerateToken(); + /** * Remove an item from the session, returning its value. * @@ -111,6 +135,21 @@ interface Session */ public function flush(); + /** + * Flush the session data and regenerate the ID. + * + * @return bool + */ + public function invalidate(); + + /** + * Generate a new session identifier. + * + * @param bool $destroy + * @return bool + */ + public function regenerate($destroy = false); + /** * Generate a new session ID for the session. * diff --git a/src/Illuminate/Contracts/Support/CanBeEscapedWhenCastToString.php b/src/Illuminate/Contracts/Support/CanBeEscapedWhenCastToString.php new file mode 100644 index 0000000000000000000000000000000000000000..e1be6fefac16fe45670e99c0c023746d961af106 --- /dev/null +++ b/src/Illuminate/Contracts/Support/CanBeEscapedWhenCastToString.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Support; + +interface CanBeEscapedWhenCastToString +{ + /** + * Indicate that the object's string representation should be escaped when __toString is invoked. + * + * @param bool $escape + * @return $this + */ + public function escapeWhenCastingToString($escape = true); +} diff --git a/src/Illuminate/Contracts/Support/DeferringDisplayableValue.php b/src/Illuminate/Contracts/Support/DeferringDisplayableValue.php new file mode 100644 index 0000000000000000000000000000000000000000..ac21d7e7f8f0c47d12c2d6e56537acdf414734e3 --- /dev/null +++ b/src/Illuminate/Contracts/Support/DeferringDisplayableValue.php @@ -0,0 +1,13 @@ +<?php + +namespace Illuminate\Contracts\Support; + +interface DeferringDisplayableValue +{ + /** + * Resolve the displayable value that the class is deferring. + * + * @return \Illuminate\Contracts\Support\Htmlable|string + */ + public function resolveDisplayableValue(); +} diff --git a/src/Illuminate/Contracts/Support/MessageBag.php b/src/Illuminate/Contracts/Support/MessageBag.php index 78bd8a00fc75b9d79f3cc9cba0024594ada6673c..7f708aca533c75051344491291d05afa24752418 100644 --- a/src/Illuminate/Contracts/Support/MessageBag.php +++ b/src/Illuminate/Contracts/Support/MessageBag.php @@ -2,7 +2,9 @@ namespace Illuminate\Contracts\Support; -interface MessageBag extends Arrayable +use Countable; + +interface MessageBag extends Arrayable, Countable { /** * Get the keys present in the message bag. @@ -97,11 +99,4 @@ interface MessageBag extends Arrayable * @return bool */ public function isNotEmpty(); - - /** - * Get the number of messages in the container. - * - * @return int - */ - public function count(); } diff --git a/src/Illuminate/Contracts/Support/ValidatedData.php b/src/Illuminate/Contracts/Support/ValidatedData.php new file mode 100644 index 0000000000000000000000000000000000000000..8e7a5207886e23ce484a8ec30267728ca43fb22d --- /dev/null +++ b/src/Illuminate/Contracts/Support/ValidatedData.php @@ -0,0 +1,11 @@ +<?php + +namespace Illuminate\Contracts\Support; + +use ArrayAccess; +use IteratorAggregate; + +interface ValidatedData extends Arrayable, ArrayAccess, IteratorAggregate +{ + // +} diff --git a/src/Illuminate/Contracts/Validation/DataAwareRule.php b/src/Illuminate/Contracts/Validation/DataAwareRule.php new file mode 100644 index 0000000000000000000000000000000000000000..7ec7ab5a9fd9a2828fdf1d10f69e5a3012e825d1 --- /dev/null +++ b/src/Illuminate/Contracts/Validation/DataAwareRule.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Validation; + +interface DataAwareRule +{ + /** + * Set the data under validation. + * + * @param array $data + * @return $this + */ + public function setData($data); +} diff --git a/src/Illuminate/Contracts/Validation/UncompromisedVerifier.php b/src/Illuminate/Contracts/Validation/UncompromisedVerifier.php new file mode 100644 index 0000000000000000000000000000000000000000..d4bd597d1a667a7124bebb16aada363eb00ae438 --- /dev/null +++ b/src/Illuminate/Contracts/Validation/UncompromisedVerifier.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Validation; + +interface UncompromisedVerifier +{ + /** + * Verify that the given data has not been compromised in data leaks. + * + * @param array $data + * @return bool + */ + public function verify($data); +} diff --git a/src/Illuminate/Contracts/Validation/Validator.php b/src/Illuminate/Contracts/Validation/Validator.php index f389d03e5080064c545b0b843c297d8329afb0dc..f68498df8f52700fa35b0fd6c3166a63f7cead81 100644 --- a/src/Illuminate/Contracts/Validation/Validator.php +++ b/src/Illuminate/Contracts/Validation/Validator.php @@ -10,6 +10,8 @@ interface Validator extends MessageProvider * Run the validator's rules against its data. * * @return array + * + * @throws \Illuminate\Validation\ValidationException */ public function validate(); @@ -17,6 +19,8 @@ interface Validator extends MessageProvider * Get the attributes and values that were validated. * * @return array + * + * @throws \Illuminate\Validation\ValidationException */ public function validated(); diff --git a/src/Illuminate/Contracts/Validation/ValidatorAwareRule.php b/src/Illuminate/Contracts/Validation/ValidatorAwareRule.php new file mode 100644 index 0000000000000000000000000000000000000000..053f4fa8b676922404a14bf6cab71c2b5f2d5228 --- /dev/null +++ b/src/Illuminate/Contracts/Validation/ValidatorAwareRule.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Contracts\Validation; + +interface ValidatorAwareRule +{ + /** + * Set the current validator. + * + * @param \Illuminate\Validation\Validator $validator + * @return $this + */ + public function setValidator($validator); +} diff --git a/src/Illuminate/Contracts/composer.json b/src/Illuminate/Contracts/composer.json index c62efd954f5da18dc2b0c28074d549939dba9dcb..c9b46671f3cbbb8b2ee73b9e3daa318f4d97028a 100644 --- a/src/Illuminate/Contracts/composer.json +++ b/src/Illuminate/Contracts/composer.json @@ -14,7 +14,7 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "psr/container": "^1.0", "psr/simple-cache": "^1.0" }, @@ -25,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Cookie/CookieJar.php b/src/Illuminate/Cookie/CookieJar.php index 337435a7468808c718ae8660b1cf284b625043ac..45f15f70e0ff1d8b1ef70377837bef90d9d18418 100755 --- a/src/Illuminate/Cookie/CookieJar.php +++ b/src/Illuminate/Cookie/CookieJar.php @@ -27,18 +27,18 @@ class CookieJar implements JarContract protected $domain; /** - * The default secure setting (defaults to false). + * The default secure setting (defaults to null). * - * @var bool + * @var bool|null */ - protected $secure = false; + protected $secure; /** - * The default SameSite option (if specified). + * The default SameSite option (defaults to lax). * * @var string */ - protected $sameSite; + protected $sameSite = 'lax'; /** * All of the cookies queued for sending. @@ -119,7 +119,7 @@ class CookieJar implements JarContract * @param string $key * @param mixed $default * @param string|null $path - * @return \Symfony\Component\HttpFoundation\Cookie + * @return \Symfony\Component\HttpFoundation\Cookie|null */ public function queued($key, $default = null, $path = null) { @@ -140,8 +140,8 @@ class CookieJar implements JarContract */ public function queue(...$parameters) { - if (head($parameters) instanceof Cookie) { - $cookie = head($parameters); + if (isset($parameters[0]) && $parameters[0] instanceof Cookie) { + $cookie = $parameters[0]; } else { $cookie = $this->make(...array_values($parameters)); } @@ -153,6 +153,19 @@ class CookieJar implements JarContract $this->queued[$cookie->getName()][$cookie->getPath()] = $cookie; } + /** + * Queue a cookie to expire with the next response. + * + * @param string $name + * @param string|null $path + * @param string|null $domain + * @return void + */ + public function expire($name, $path = null, $domain = null) + { + $this->queue($this->forget($name, $path, $domain)); + } + /** * Remove a cookie from the queue. * @@ -214,4 +227,16 @@ class CookieJar implements JarContract { return Arr::flatten($this->queued); } + + /** + * Flush the cookies which have been queued for the next request. + * + * @return $this + */ + public function flushQueuedCookies() + { + $this->queued = []; + + return $this; + } } diff --git a/src/Illuminate/Cookie/Middleware/EncryptCookies.php b/src/Illuminate/Cookie/Middleware/EncryptCookies.php index c286588479aa8e3f02428827c106817fab228bb3..4a116cfb3301513be5b594070cedd3b8bd2ce149 100644 --- a/src/Illuminate/Cookie/Middleware/EncryptCookies.php +++ b/src/Illuminate/Cookie/Middleware/EncryptCookies.php @@ -76,7 +76,7 @@ class EncryptCookies protected function decrypt(Request $request) { foreach ($request->cookies as $key => $cookie) { - if ($this->isDisabled($key)) { + if ($this->isDisabled($key) || is_array($cookie)) { continue; } diff --git a/src/Illuminate/Cookie/composer.json b/src/Illuminate/Cookie/composer.json index 920d7fff18fce5ccea8987f097efbb2594e2ab73..fb587b754766cb90011d98e6ff1fb40abb7f9564 100755 --- a/src/Illuminate/Cookie/composer.json +++ b/src/Illuminate/Cookie/composer.json @@ -14,11 +14,13 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "symfony/http-foundation": "^4.3.4", - "symfony/http-kernel": "^4.3.4" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "symfony/http-foundation": "^5.4", + "symfony/http-kernel": "^5.4" }, "autoload": { "psr-4": { @@ -27,7 +29,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Database/ClassMorphViolationException.php b/src/Illuminate/Database/ClassMorphViolationException.php new file mode 100644 index 0000000000000000000000000000000000000000..6594d2d902385b9b09fd270111b5bb06f4bc2cc3 --- /dev/null +++ b/src/Illuminate/Database/ClassMorphViolationException.php @@ -0,0 +1,29 @@ +<?php + +namespace Illuminate\Database; + +use RuntimeException; + +class ClassMorphViolationException extends RuntimeException +{ + /** + * The name of the affected Eloquent model. + * + * @var string + */ + public $model; + + /** + * Create a new exception instance. + * + * @param object $model + */ + public function __construct($model) + { + $class = get_class($model); + + parent::__construct("No morph map defined for model [{$class}]."); + + $this->model = $class; + } +} diff --git a/src/Illuminate/Database/Concerns/BuildsQueries.php b/src/Illuminate/Database/Concerns/BuildsQueries.php index 3c7b436538610f70de3e88e52b09b0e78f5d8e0e..7a49d77461d4ec2da29cbec346d5c1c184ab3ccd 100644 --- a/src/Illuminate/Database/Concerns/BuildsQueries.php +++ b/src/Illuminate/Database/Concerns/BuildsQueries.php @@ -3,11 +3,23 @@ namespace Illuminate\Database\Concerns; use Illuminate\Container\Container; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\MultipleRecordsFoundException; +use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; +use Illuminate\Support\Collection; +use Illuminate\Support\LazyCollection; +use Illuminate\Support\Traits\Conditionable; +use InvalidArgumentException; +use RuntimeException; trait BuildsQueries { + use Conditionable; + /** * Chunk the results of the query. * @@ -48,12 +60,34 @@ trait BuildsQueries return true; } + /** + * Run a map over each item while chunking. + * + * @param callable $callback + * @param int $count + * @return \Illuminate\Support\Collection + */ + public function chunkMap(callable $callback, $count = 1000) + { + $collection = Collection::make(); + + $this->chunk($count, function ($items) use ($collection, $callback) { + $items->each(function ($item) use ($collection, $callback) { + $collection->push($callback($item)); + }); + }); + + return $collection; + } + /** * Execute a callback over each item while chunking. * * @param callable $callback * @param int $count * @return bool + * + * @throws \RuntimeException */ public function each(callable $callback, $count = 1000) { @@ -83,6 +117,8 @@ trait BuildsQueries $lastId = null; + $page = 1; + do { $clone = clone $this; @@ -100,20 +136,26 @@ trait BuildsQueries // On each chunk result set, we will pass them to the callback and then let the // developer take care of everything within the callback, which allows us to // keep the memory low for spinning through large result sets for working. - if ($callback($results) === false) { + if ($callback($results, $page) === false) { return false; } $lastId = $results->last()->{$alias}; + if ($lastId === null) { + throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result."); + } + unset($results); + + $page++; } while ($countResults == $count); return true; } /** - * Execute a callback over each item while chunking by id. + * Execute a callback over each item while chunking by ID. * * @param callable $callback * @param int $count @@ -123,15 +165,124 @@ trait BuildsQueries */ public function eachById(callable $callback, $count = 1000, $column = null, $alias = null) { - return $this->chunkById($count, function ($results) use ($callback) { + return $this->chunkById($count, function ($results, $page) use ($callback, $count) { foreach ($results as $key => $value) { - if ($callback($value, $key) === false) { + if ($callback($value, (($page - 1) * $count) + $key) === false) { return false; } } }, $column, $alias); } + /** + * Query lazily, by chunks of the given size. + * + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + * + * @throws \InvalidArgumentException + */ + public function lazy($chunkSize = 1000) + { + if ($chunkSize < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + + $this->enforceOrderBy(); + + return LazyCollection::make(function () use ($chunkSize) { + $page = 1; + + while (true) { + $results = $this->forPage($page++, $chunkSize)->get(); + + foreach ($results as $result) { + yield $result; + } + + if ($results->count() < $chunkSize) { + return; + } + } + }); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @param int $chunkSize + * @param string|null $column + * @param string|null $alias + * @return \Illuminate\Support\LazyCollection + * + * @throws \InvalidArgumentException + */ + public function lazyById($chunkSize = 1000, $column = null, $alias = null) + { + return $this->orderedLazyById($chunkSize, $column, $alias); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in descending order. + * + * @param int $chunkSize + * @param string|null $column + * @param string|null $alias + * @return \Illuminate\Support\LazyCollection + * + * @throws \InvalidArgumentException + */ + public function lazyByIdDesc($chunkSize = 1000, $column = null, $alias = null) + { + return $this->orderedLazyById($chunkSize, $column, $alias, true); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in a given order. + * + * @param int $chunkSize + * @param string|null $column + * @param string|null $alias + * @param bool $descending + * @return \Illuminate\Support\LazyCollection + * + * @throws \InvalidArgumentException + */ + protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = null, $descending = false) + { + if ($chunkSize < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + + $column = $column ?? $this->defaultKeyName(); + + $alias = $alias ?? $column; + + return LazyCollection::make(function () use ($chunkSize, $column, $alias, $descending) { + $lastId = null; + + while (true) { + $clone = clone $this; + + if ($descending) { + $results = $clone->forPageBeforeId($chunkSize, $lastId, $column)->get(); + } else { + $results = $clone->forPageAfterId($chunkSize, $lastId, $column)->get(); + } + + foreach ($results as $result) { + yield $result; + } + + if ($results->count() < $chunkSize) { + return; + } + + $lastId = $results->last()->{$alias}; + } + }); + } + /** * Execute the query and get the first result. * @@ -144,52 +295,143 @@ trait BuildsQueries } /** - * Apply the callback's query changes if the given "value" is true. + * Execute the query and get the first result if it's the sole matching record. * - * @param mixed $value - * @param callable $callback - * @param callable|null $default - * @return mixed|$this + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Model|object|static|null + * + * @throws \Illuminate\Database\RecordsNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException */ - public function when($value, $callback, $default = null) + public function sole($columns = ['*']) { - if ($value) { - return $callback($this, $value) ?: $this; - } elseif ($default) { - return $default($this, $value) ?: $this; + $result = $this->take(2)->get($columns); + + if ($result->isEmpty()) { + throw new RecordsNotFoundException; } - return $this; + if ($result->count() > 1) { + throw new MultipleRecordsFoundException; + } + + return $result->first(); } /** - * Pass the query to a given callback. + * Paginate the given query using a cursor paginator. * - * @param callable $callback - * @return $this + * @param int $perPage + * @param array $columns + * @param string $cursorName + * @param \Illuminate\Pagination\Cursor|string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator */ - public function tap($callback) + protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = 'cursor', $cursor = null) { - return $this->when(true, $callback); + if (! $cursor instanceof Cursor) { + $cursor = is_string($cursor) + ? Cursor::fromEncoded($cursor) + : CursorPaginator::resolveCurrentCursor($cursorName, $cursor); + } + + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + + if (! is_null($cursor)) { + $addCursorConditions = function (self $builder, $previousColumn, $i) use (&$addCursorConditions, $cursor, $orders) { + $unionBuilders = isset($builder->unions) ? collect($builder->unions)->pluck('query') : collect(); + + if (! is_null($previousColumn)) { + $builder->where( + $this->getOriginalColumnNameForCursorPagination($this, $previousColumn), + '=', + $cursor->parameter($previousColumn) + ); + + $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) { + $unionBuilder->where( + $this->getOriginalColumnNameForCursorPagination($this, $previousColumn), + '=', + $cursor->parameter($previousColumn) + ); + + $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); + }); + } + + $builder->where(function (self $builder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { + ['column' => $column, 'direction' => $direction] = $orders[$i]; + + $builder->where( + $this->getOriginalColumnNameForCursorPagination($this, $column), + $direction === 'asc' ? '>' : '<', + $cursor->parameter($column) + ); + + if ($i < $orders->count() - 1) { + $builder->orWhere(function (self $builder) use ($addCursorConditions, $column, $i) { + $addCursorConditions($builder, $column, $i + 1); + }); + } + + $unionBuilders->each(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions) { + $unionBuilder->where(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions) { + $unionBuilder->where( + $this->getOriginalColumnNameForCursorPagination($this, $column), + $direction === 'asc' ? '>' : '<', + $cursor->parameter($column) + ); + + if ($i < $orders->count() - 1) { + $unionBuilder->orWhere(function (self $builder) use ($addCursorConditions, $column, $i) { + $addCursorConditions($builder, $column, $i + 1); + }); + } + + $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); + }); + }); + }); + }; + + $addCursorConditions($this, null, 0); + } + + $this->limit($perPage + 1); + + return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + 'path' => Paginator::resolveCurrentPath(), + 'cursorName' => $cursorName, + 'parameters' => $orders->pluck('column')->toArray(), + ]); } /** - * Apply the callback's query changes if the given "value" is false. + * Get the original column name of the given column, without any aliasing. * - * @param mixed $value - * @param callable $callback - * @param callable|null $default - * @return mixed|$this + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder + * @param string $parameter + * @return string */ - public function unless($value, $callback, $default = null) + protected function getOriginalColumnNameForCursorPagination($builder, string $parameter) { - if (! $value) { - return $callback($this, $value) ?: $this; - } elseif ($default) { - return $default($this, $value) ?: $this; + $columns = $builder instanceof Builder ? $builder->getQuery()->columns : $builder->columns; + + if (! is_null($columns)) { + foreach ($columns as $column) { + if (($position = stripos($column, ' as ')) !== false) { + $as = substr($column, $position, 4); + + [$original, $alias] = explode($as, $column); + + if ($parameter === $alias) { + return $original; + } + } + } } - return $this; + return $parameter; } /** @@ -224,4 +466,31 @@ trait BuildsQueries 'items', 'perPage', 'currentPage', 'options' )); } + + /** + * Create a new cursor paginator instance. + * + * @param \Illuminate\Support\Collection $items + * @param int $perPage + * @param \Illuminate\Pagination\Cursor $cursor + * @param array $options + * @return \Illuminate\Pagination\CursorPaginator + */ + protected function cursorPaginator($items, $perPage, $cursor, $options) + { + return Container::getInstance()->makeWith(CursorPaginator::class, compact( + 'items', 'perPage', 'cursor', 'options' + )); + } + + /** + * Pass the query to a given callback. + * + * @param callable $callback + * @return $this|mixed + */ + public function tap($callback) + { + return $this->when(true, $callback); + } } diff --git a/src/Illuminate/Database/Concerns/ExplainsQueries.php b/src/Illuminate/Database/Concerns/ExplainsQueries.php new file mode 100644 index 0000000000000000000000000000000000000000..7168de1e55cf4377bac3ba3df469811556d3a952 --- /dev/null +++ b/src/Illuminate/Database/Concerns/ExplainsQueries.php @@ -0,0 +1,24 @@ +<?php + +namespace Illuminate\Database\Concerns; + +use Illuminate\Support\Collection; + +trait ExplainsQueries +{ + /** + * Explains the query. + * + * @return \Illuminate\Support\Collection + */ + public function explain() + { + $sql = $this->toSql(); + + $bindings = $this->getBindings(); + + $explanation = $this->getConnection()->select('EXPLAIN '.$sql, $bindings); + + return new Collection($explanation); + } +} diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 5eab7a461e29014a3cbddf61241e62e108b29faf..fac70295de064b7735e13d0553eff593174d556f 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -3,7 +3,7 @@ namespace Illuminate\Database\Concerns; use Closure; -use Exception; +use RuntimeException; use Throwable; trait ManagesTransactions @@ -15,7 +15,7 @@ trait ManagesTransactions * @param int $attempts * @return mixed * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function transaction(Closure $callback, $attempts = 1) { @@ -32,16 +32,12 @@ trait ManagesTransactions // If we catch an exception we'll rollback this transaction and try again if we // are not out of attempts. If we are out of attempts we will just throw the // exception back out and let the developer handle an uncaught exceptions. - catch (Exception $e) { + catch (Throwable $e) { $this->handleTransactionException( $e, $currentAttempt, $attempts ); continue; - } catch (Throwable $e) { - $this->rollBack(); - - throw $e; } try { @@ -50,9 +46,11 @@ trait ManagesTransactions } $this->transactions = max(0, $this->transactions - 1); - } catch (Exception $e) { - $commitFailed = true; + if ($this->transactions == 0) { + optional($this->transactionsManager)->commit($this->getName()); + } + } catch (Throwable $e) { $this->handleCommitTransactionException( $e, $currentAttempt, $attempts ); @@ -60,9 +58,7 @@ trait ManagesTransactions continue; } - if (! isset($commitFailed)) { - $this->fireConnectionEvent('committed'); - } + $this->fireConnectionEvent('committed'); return $callbackResult; } @@ -71,14 +67,14 @@ trait ManagesTransactions /** * Handle an exception encountered when running a transacted statement. * - * @param \Exception $e + * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleTransactionException($e, $currentAttempt, $maxAttempts) + protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts) { // On a deadlock, MySQL rolls back the entire transaction so we can't just // retry the query. We have to throw this exception all the way out and @@ -87,6 +83,10 @@ trait ManagesTransactions $this->transactions > 1) { $this->transactions--; + optional($this->transactionsManager)->rollback( + $this->getName(), $this->transactions + ); + throw $e; } @@ -108,7 +108,7 @@ trait ManagesTransactions * * @return void * - * @throws \Exception + * @throws \Throwable */ public function beginTransaction() { @@ -116,6 +116,10 @@ trait ManagesTransactions $this->transactions++; + optional($this->transactionsManager)->begin( + $this->getName(), $this->transactions + ); + $this->fireConnectionEvent('beganTransaction'); } @@ -123,6 +127,8 @@ trait ManagesTransactions * Create a transaction within the database. * * @return void + * + * @throws \Throwable */ protected function createTransaction() { @@ -131,7 +137,7 @@ trait ManagesTransactions try { $this->getPdo()->beginTransaction(); - } catch (Exception $e) { + } catch (Throwable $e) { $this->handleBeginTransactionException($e); } } elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) { @@ -143,6 +149,8 @@ trait ManagesTransactions * Create a save point within the database. * * @return void + * + * @throws \Throwable */ protected function createSavepoint() { @@ -157,9 +165,9 @@ trait ManagesTransactions * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleBeginTransactionException($e) + protected function handleBeginTransactionException(Throwable $e) { if ($this->causedByLostConnection($e)) { $this->reconnect(); @@ -174,6 +182,8 @@ trait ManagesTransactions * Commit the active database transaction. * * @return void + * + * @throws \Throwable */ public function commit() { @@ -183,18 +193,24 @@ trait ManagesTransactions $this->transactions = max(0, $this->transactions - 1); + if ($this->transactions == 0) { + optional($this->transactionsManager)->commit($this->getName()); + } + $this->fireConnectionEvent('committed'); } /** * Handle an exception encountered when committing a transaction. * - * @param \Exception $e + * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts * @return void + * + * @throws \Throwable */ - protected function handleCommitTransactionException($e, $currentAttempt, $maxAttempts) + protected function handleCommitTransactionException(Throwable $e, $currentAttempt, $maxAttempts) { $this->transactions = max(0, $this->transactions - 1); @@ -216,7 +232,7 @@ trait ManagesTransactions * @param int|null $toLevel * @return void * - * @throws \Exception + * @throws \Throwable */ public function rollBack($toLevel = null) { @@ -236,12 +252,16 @@ trait ManagesTransactions // level that was passed into this method so it will be right from here out. try { $this->performRollBack($toLevel); - } catch (Exception $e) { + } catch (Throwable $e) { $this->handleRollBackException($e); } $this->transactions = $toLevel; + optional($this->transactionsManager)->rollback( + $this->getName(), $this->transactions + ); + $this->fireConnectionEvent('rollingBack'); } @@ -250,6 +270,8 @@ trait ManagesTransactions * * @param int $toLevel * @return void + * + * @throws \Throwable */ protected function performRollBack($toLevel) { @@ -265,15 +287,19 @@ trait ManagesTransactions /** * Handle an exception from a rollback. * - * @param \Exception $e + * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleRollBackException($e) + protected function handleRollBackException(Throwable $e) { if ($this->causedByLostConnection($e)) { $this->transactions = 0; + + optional($this->transactionsManager)->rollback( + $this->getName(), $this->transactions + ); } throw $e; @@ -288,4 +314,21 @@ trait ManagesTransactions { return $this->transactions; } + + /** + * Execute the callback after a transaction commits. + * + * @param callable $callback + * @return void + * + * @throws \RuntimeException + */ + public function afterCommit($callback) + { + if ($this->transactionsManager) { + return $this->transactionsManager->addCallback($callback); + } + + throw new RuntimeException('Transactions Manager has not been set.'); + } } diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 7c688fdbe5a259f16c69cd53114f812322885a3c..87c17f199fa6c8151325467070da05ed76a6e7b9 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -5,6 +5,7 @@ namespace Illuminate\Database; use Closure; use DateTimeInterface; use Doctrine\DBAL\Connection as DoctrineConnection; +use Doctrine\DBAL\Types\Type; use Exception; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Events\QueryExecuted; @@ -21,6 +22,7 @@ use Illuminate\Support\Arr; use LogicException; use PDO; use PDOStatement; +use RuntimeException; class Connection implements ConnectionInterface { @@ -49,6 +51,13 @@ class Connection implements ConnectionInterface */ protected $database; + /** + * The type of the connection. + * + * @var string|null + */ + protected $readWriteType; + /** * The table prefix for the connection. * @@ -112,13 +121,27 @@ class Connection implements ConnectionInterface */ protected $transactions = 0; + /** + * The transaction manager instance. + * + * @var \Illuminate\Database\DatabaseTransactionsManager + */ + protected $transactionsManager; + /** * Indicates if changes have been made to the database. * - * @var int + * @var bool */ protected $recordsModified = false; + /** + * Indicates if the connection should use the "write" PDO connection. + * + * @var bool + */ + protected $readOnWriteConnection = false; + /** * All of the queries run against the connection. * @@ -140,6 +163,13 @@ class Connection implements ConnectionInterface */ protected $pretending = false; + /** + * All of the callbacks that should be invoked before a query is executed. + * + * @var array + */ + protected $beforeExecutingCallbacks = []; + /** * The instance of Doctrine connection. * @@ -147,6 +177,13 @@ class Connection implements ConnectionInterface */ protected $doctrineConnection; + /** + * Type mappings that should be registered with new Doctrine connections. + * + * @var array + */ + protected $doctrineTypeMappings = []; + /** * The connection resolvers. * @@ -327,8 +364,9 @@ class Connection implements ConnectionInterface // For select statements, we'll simply execute the query and return an array // of the database result set. Each element in the array will be a single // row from the database table, and will either be an array or objects. - $statement = $this->prepared($this->getPdoForSelect($useReadPdo) - ->prepare($query)); + $statement = $this->prepared( + $this->getPdoForSelect($useReadPdo)->prepare($query) + ); $this->bindValues($statement, $this->prepareBindings($bindings)); @@ -576,7 +614,8 @@ class Connection implements ConnectionInterface { foreach ($bindings as $key => $value) { $statement->bindValue( - is_string($key) ? $key : $key + 1, $value, + is_string($key) ? $key : $key + 1, + $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR ); } @@ -618,6 +657,10 @@ class Connection implements ConnectionInterface */ protected function run($query, $bindings, Closure $callback) { + foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) { + $beforeExecutingCallback($query, $bindings, $this); + } + $this->reconnectIfMissingConnection(); $start = microtime(true); @@ -659,7 +702,7 @@ class Connection implements ConnectionInterface // run the SQL against the PDO connection. Then we can calculate the time it // took to execute and log the query SQL, bindings and time in our memory. try { - $result = $callback($query, $bindings); + return $callback($query, $bindings); } // If an exception occurs when attempting to run a query, we'll format the error @@ -670,8 +713,6 @@ class Connection implements ConnectionInterface $query, $this->prepareBindings($bindings), $e ); } - - return $result; } /** @@ -784,6 +825,21 @@ class Connection implements ConnectionInterface public function disconnect() { $this->setPdo(null)->setReadPdo(null); + + $this->doctrineConnection = null; + } + + /** + * Register a hook to be run just before a database query is executed. + * + * @param \Closure $callback + * @return $this + */ + public function beforeExecuting(Closure $callback) + { + $this->beforeExecutingCallbacks[] = $callback; + + return $this; } /** @@ -845,6 +901,16 @@ class Connection implements ConnectionInterface return new Expression($value); } + /** + * Determine if the database connection has modified any database records. + * + * @return bool + */ + public function hasModifiedRecords() + { + return $this->recordsModified; + } + /** * Indicate if any records have been modified. * @@ -858,6 +924,42 @@ class Connection implements ConnectionInterface } } + /** + * Set the record modification state. + * + * @param bool $value + * @return $this + */ + public function setRecordModificationState(bool $value) + { + $this->recordsModified = $value; + + return $this; + } + + /** + * Reset the record modification state. + * + * @return void + */ + public function forgetRecordModificationState() + { + $this->recordsModified = false; + } + + /** + * Indicate that the connection should use the write PDO connection for reads. + * + * @param bool $value + * @return $this + */ + public function useWriteConnectionWhenReading($value = true) + { + $this->readOnWriteConnection = $value; + + return $this; + } + /** * Is Doctrine available? * @@ -889,7 +991,13 @@ class Connection implements ConnectionInterface */ public function getDoctrineSchemaManager() { - return $this->getDoctrineDriver()->getSchemaManager($this->getDoctrineConnection()); + $connection = $this->getDoctrineConnection(); + + // Doctrine v2 expects one parameter while v3 expects two. 2nd will be ignored on v2... + return $this->getDoctrineDriver()->getSchemaManager( + $connection, + $connection->getDatabasePlatform() + ); } /** @@ -905,14 +1013,46 @@ class Connection implements ConnectionInterface $this->doctrineConnection = new DoctrineConnection(array_filter([ 'pdo' => $this->getPdo(), 'dbname' => $this->getDatabaseName(), - 'driver' => $driver->getName(), + 'driver' => method_exists($driver, 'getName') ? $driver->getName() : null, 'serverVersion' => $this->getConfig('server_version'), ]), $driver); + + foreach ($this->doctrineTypeMappings as $name => $type) { + $this->doctrineConnection + ->getDatabasePlatform() + ->registerDoctrineTypeMapping($type, $name); + } } return $this->doctrineConnection; } + /** + * Register a custom Doctrine mapping type. + * + * @param string $class + * @param string $name + * @param string $type + * @return void + * + * @throws \Doctrine\DBAL\DBALException + * @throws \RuntimeException + */ + public function registerDoctrineType(string $class, string $name, string $type): void + { + if (! $this->isDoctrineAvailable()) { + throw new RuntimeException( + 'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).' + ); + } + + if (! Type::hasType($name)) { + Type::addType($name, $class); + } + + $this->doctrineTypeMappings[$name] = $type; + } + /** * Get the current PDO connection. * @@ -948,7 +1088,8 @@ class Connection implements ConnectionInterface return $this->getPdo(); } - if ($this->recordsModified && $this->getConfig('sticky')) { + if ($this->readOnWriteConnection || + ($this->recordsModified && $this->getConfig('sticky'))) { return $this->getPdo(); } @@ -1020,6 +1161,16 @@ class Connection implements ConnectionInterface return $this->getConfig('name'); } + /** + * Get the database connection full name. + * + * @return string|null + */ + public function getNameWithReadWriteType() + { + return $this->getName().($this->readWriteType ? '::'.$this->readWriteType : ''); + } + /** * Get an option from the configuration options. * @@ -1143,6 +1294,29 @@ class Connection implements ConnectionInterface $this->events = null; } + /** + * Set the transaction manager instance on the connection. + * + * @param \Illuminate\Database\DatabaseTransactionsManager $manager + * @return $this + */ + public function setTransactionManager($manager) + { + $this->transactionsManager = $manager; + + return $this; + } + + /** + * Unset the transaction manager for this connection. + * + * @return void + */ + public function unsetTransactionManager() + { + $this->transactionsManager = null; + } + /** * Determine if the connection is in a "dry run". * @@ -1226,6 +1400,19 @@ class Connection implements ConnectionInterface return $this; } + /** + * Set the read / write type of the connection. + * + * @param string|null $readWriteType + * @return $this + */ + public function setReadWriteType($readWriteType) + { + $this->readWriteType = $readWriteType; + + return $this; + } + /** * Get the table prefix for the connection. * diff --git a/src/Illuminate/Database/ConnectionInterface.php b/src/Illuminate/Database/ConnectionInterface.php index c7e24b1ab70f67c02f6620c31515cdd6c0225960..00b23952a3c0d721b764dea5755eb3637b04820e 100755 --- a/src/Illuminate/Database/ConnectionInterface.php +++ b/src/Illuminate/Database/ConnectionInterface.php @@ -160,4 +160,11 @@ interface ConnectionInterface * @return array */ public function pretend(Closure $callback); + + /** + * Get the name of the connected database. + * + * @return string + */ + public function getDatabaseName(); } diff --git a/src/Illuminate/Database/Connectors/ConnectionFactory.php b/src/Illuminate/Database/Connectors/ConnectionFactory.php index 1a2e37ec4e7abd54d52f136e60cbc2f7002c9ccc..ad465055032315b56ef3d849bb1cb2c2ed045c45 100755 --- a/src/Illuminate/Database/Connectors/ConnectionFactory.php +++ b/src/Illuminate/Database/Connectors/ConnectionFactory.php @@ -78,7 +78,7 @@ class ConnectionFactory } /** - * Create a single database connection instance. + * Create a read / write database connection instance. * * @param array $config * @return \Illuminate\Database\Connection @@ -115,7 +115,7 @@ class ConnectionFactory } /** - * Get the read configuration for a read / write connection. + * Get the write configuration for a read / write connection. * * @param array $config * @return array @@ -171,6 +171,8 @@ class ConnectionFactory * * @param array $config * @return \Closure + * + * @throws \PDOException */ protected function createPdoResolverWithHosts(array $config) { @@ -250,7 +252,7 @@ class ConnectionFactory return new SqlServerConnector; } - throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]"); + throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]."); } /** @@ -282,6 +284,6 @@ class ConnectionFactory return new SqlServerConnection($connection, $database, $prefix, $config); } - throw new InvalidArgumentException("Unsupported driver [{$driver}]"); + throw new InvalidArgumentException("Unsupported driver [{$driver}]."); } } diff --git a/src/Illuminate/Database/Connectors/MySqlConnector.php b/src/Illuminate/Database/Connectors/MySqlConnector.php index b1885a220203aadae4af128392a5864442bdde05..a7640859d77c5c557bde863b433d1ba12640afef 100755 --- a/src/Illuminate/Database/Connectors/MySqlConnector.php +++ b/src/Illuminate/Database/Connectors/MySqlConnector.php @@ -27,6 +27,8 @@ class MySqlConnector extends Connector implements ConnectorInterface $connection->exec("use `{$config['database']}`;"); } + $this->configureIsolationLevel($connection, $config); + $this->configureEncoding($connection, $config); // Next, we will check to see if a timezone has been specified in this config @@ -40,12 +42,30 @@ class MySqlConnector extends Connector implements ConnectorInterface } /** - * Set the connection character set and collation. + * Set the connection transaction isolation level. * * @param \PDO $connection * @param array $config * @return void */ + protected function configureIsolationLevel($connection, array $config) + { + if (! isset($config['isolation_level'])) { + return; + } + + $connection->prepare( + "SET SESSION TRANSACTION ISOLATION LEVEL {$config['isolation_level']}" + )->execute(); + } + + /** + * Set the connection character set and collation. + * + * @param \PDO $connection + * @param array $config + * @return void|\PDO + */ protected function configureEncoding($connection, array $config) { if (! isset($config['charset'])) { @@ -147,7 +167,7 @@ class MySqlConnector extends Connector implements ConnectorInterface $this->setCustomModes($connection, $config); } elseif (isset($config['strict'])) { if ($config['strict']) { - $connection->prepare($this->strictMode($connection))->execute(); + $connection->prepare($this->strictMode($connection, $config))->execute(); } else { $connection->prepare("set session sql_mode='NO_ENGINE_SUBSTITUTION'")->execute(); } @@ -172,11 +192,14 @@ class MySqlConnector extends Connector implements ConnectorInterface * Get the query to enable strict mode. * * @param \PDO $connection + * @param array $config * @return string */ - protected function strictMode(PDO $connection) + protected function strictMode(PDO $connection, $config) { - if (version_compare($connection->getAttribute(PDO::ATTR_SERVER_VERSION), '8.0.11') >= 0) { + $version = $config['version'] ?? $connection->getAttribute(PDO::ATTR_SERVER_VERSION); + + if (version_compare($version, '8.0.11') >= 0) { return "set session sql_mode='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"; } diff --git a/src/Illuminate/Database/Connectors/PostgresConnector.php b/src/Illuminate/Database/Connectors/PostgresConnector.php index c40369d75f94b25deac868112629c8ac1e6e451d..a507d9ac3c41c8f00f96b30febcd47c679dede1e 100755 --- a/src/Illuminate/Database/Connectors/PostgresConnector.php +++ b/src/Illuminate/Database/Connectors/PostgresConnector.php @@ -33,6 +33,8 @@ class PostgresConnector extends Connector implements ConnectorInterface $this->getDsn($config), $config, $this->getOptions($config) ); + $this->configureIsolationLevel($connection, $config); + $this->configureEncoding($connection, $config); // Next, we will check to see if a timezone has been specified in this config @@ -47,9 +49,25 @@ class PostgresConnector extends Connector implements ConnectorInterface // determine if the option has been specified and run a statement if so. $this->configureApplicationName($connection, $config); + $this->configureSynchronousCommit($connection, $config); + return $connection; } + /** + * Set the connection transaction isolation level. + * + * @param \PDO $connection + * @param array $config + * @return void + */ + protected function configureIsolationLevel($connection, array $config) + { + if (isset($config['isolation_level'])) { + $connection->prepare("set session characteristics as transaction isolation level {$config['isolation_level']}")->execute(); + } + } + /** * Set the connection character set and collation. * @@ -144,7 +162,7 @@ class PostgresConnector extends Connector implements ConnectorInterface $host = isset($host) ? "host={$host};" : ''; - $dsn = "pgsql:{$host}dbname={$database}"; + $dsn = "pgsql:{$host}dbname='{$database}'"; // If a port was specified, we will add it to this Postgres DSN connections // format. Once we have done that we are ready to return this connection @@ -173,4 +191,20 @@ class PostgresConnector extends Connector implements ConnectorInterface return $dsn; } + + /** + * Configure the synchronous_commit setting. + * + * @param \PDO $connection + * @param array $config + * @return void + */ + protected function configureSynchronousCommit($connection, array $config) + { + if (! isset($config['synchronous_commit'])) { + return; + } + + $connection->prepare("set synchronous_commit to '{$config['synchronous_commit']}'")->execute(); + } } diff --git a/src/Illuminate/Database/Connectors/SqlServerConnector.php b/src/Illuminate/Database/Connectors/SqlServerConnector.php index 0424642e238571d28ae2c4ab9cba4238dc87c79d..caefa684693fe395282cf7a9a7af896c1669d9c9 100755 --- a/src/Illuminate/Database/Connectors/SqlServerConnector.php +++ b/src/Illuminate/Database/Connectors/SqlServerConnector.php @@ -156,6 +156,10 @@ class SqlServerConnector extends Connector implements ConnectorInterface $arguments['KeyStoreSecret'] = $config['key_store_secret']; } + if (isset($config['login_timeout'])) { + $arguments['LoginTimeout'] = $config['login_timeout']; + } + return $this->buildConnectString('sqlsrv', $arguments); } diff --git a/src/Illuminate/Database/Console/DbCommand.php b/src/Illuminate/Database/Console/DbCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..c2c459352b07c40bce03512c7f0e5cf1fc434081 --- /dev/null +++ b/src/Illuminate/Database/Console/DbCommand.php @@ -0,0 +1,219 @@ +<?php + +namespace Illuminate\Database\Console; + +use Illuminate\Console\Command; +use Illuminate\Support\ConfigurationUrlParser; +use Symfony\Component\Process\Process; +use UnexpectedValueException; + +class DbCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'db {connection? : The database connection that should be used} + {--read : Connect to the read connection} + {--write : Connect to the write connection}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Start a new database CLI session'; + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $connection = $this->getConnection(); + + (new Process( + array_merge([$this->getCommand($connection)], $this->commandArguments($connection)), + null, + $this->commandEnvironment($connection) + ))->setTimeout(null)->setTty(true)->mustRun(function ($type, $buffer) { + $this->output->write($buffer); + }); + + return 0; + } + + /** + * Get the database connection configuration. + * + * @return array + * + * @throws \UnexpectedValueException + */ + public function getConnection() + { + $connection = $this->laravel['config']['database.connections.'. + (($db = $this->argument('connection')) ?? $this->laravel['config']['database.default']) + ]; + + if (empty($connection)) { + throw new UnexpectedValueException("Invalid database connection [{$db}]."); + } + + if (! empty($connection['url'])) { + $connection = (new ConfigurationUrlParser)->parseConfiguration($connection); + } + + if ($this->option('read')) { + if (is_array($connection['read']['host'])) { + $connection['read']['host'] = $connection['read']['host'][0]; + } + + $connection = array_merge($connection, $connection['read']); + } elseif ($this->option('write')) { + if (is_array($connection['write']['host'])) { + $connection['write']['host'] = $connection['write']['host'][0]; + } + + $connection = array_merge($connection, $connection['write']); + } + + return $connection; + } + + /** + * Get the arguments for the database client command. + * + * @param array $connection + * @return array + */ + public function commandArguments(array $connection) + { + $driver = ucfirst($connection['driver']); + + return $this->{"get{$driver}Arguments"}($connection); + } + + /** + * Get the environment variables for the database client command. + * + * @param array $connection + * @return array|null + */ + public function commandEnvironment(array $connection) + { + $driver = ucfirst($connection['driver']); + + if (method_exists($this, "get{$driver}Environment")) { + return $this->{"get{$driver}Environment"}($connection); + } + + return null; + } + + /** + * Get the database client command to run. + * + * @param array $connection + * @return string + */ + public function getCommand(array $connection) + { + return [ + 'mysql' => 'mysql', + 'pgsql' => 'psql', + 'sqlite' => 'sqlite3', + 'sqlsrv' => 'sqlcmd', + ][$connection['driver']]; + } + + /** + * Get the arguments for the MySQL CLI. + * + * @param array $connection + * @return array + */ + protected function getMysqlArguments(array $connection) + { + return array_merge([ + '--host='.$connection['host'], + '--port='.$connection['port'], + '--user='.$connection['username'], + ], $this->getOptionalArguments([ + 'password' => '--password='.$connection['password'], + 'unix_socket' => '--socket='.($connection['unix_socket'] ?? ''), + 'charset' => '--default-character-set='.($connection['charset'] ?? ''), + ], $connection), [$connection['database']]); + } + + /** + * Get the arguments for the Postgres CLI. + * + * @param array $connection + * @return array + */ + protected function getPgsqlArguments(array $connection) + { + return [$connection['database']]; + } + + /** + * Get the arguments for the SQLite CLI. + * + * @param array $connection + * @return array + */ + protected function getSqliteArguments(array $connection) + { + return [$connection['database']]; + } + + /** + * Get the arguments for the SQL Server CLI. + * + * @param array $connection + * @return array + */ + protected function getSqlsrvArguments(array $connection) + { + return array_merge(...$this->getOptionalArguments([ + 'database' => ['-d', $connection['database']], + 'username' => ['-U', $connection['username']], + 'password' => ['-P', $connection['password']], + 'host' => ['-S', 'tcp:'.$connection['host'] + .($connection['port'] ? ','.$connection['port'] : ''), ], + ], $connection)); + } + + /** + * Get the environment variables for the Postgres CLI. + * + * @param array $connection + * @return array|null + */ + protected function getPgsqlEnvironment(array $connection) + { + return array_merge(...$this->getOptionalArguments([ + 'username' => ['PGUSER' => $connection['username']], + 'host' => ['PGHOST' => $connection['host']], + 'port' => ['PGPORT' => $connection['port']], + 'password' => ['PGPASSWORD' => $connection['password']], + ], $connection)); + } + + /** + * Get the optional arguments based on the connection configuration. + * + * @param array $args + * @param array $connection + * @return array + */ + protected function getOptionalArguments(array $args, array $connection) + { + return array_values(array_filter($args, function ($key) use ($connection) { + return ! empty($connection[$key]); + }, ARRAY_FILTER_USE_KEY)); + } +} diff --git a/src/Illuminate/Database/Console/DumpCommand.php b/src/Illuminate/Database/Console/DumpCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..fe73fb2af0330cced87704adac401f6538331476 --- /dev/null +++ b/src/Illuminate/Database/Console/DumpCommand.php @@ -0,0 +1,86 @@ +<?php + +namespace Illuminate\Database\Console; + +use Illuminate\Console\Command; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Connection; +use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Events\SchemaDumped; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Config; + +class DumpCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $signature = 'schema:dump + {--database= : The database connection to use} + {--path= : The path where the schema dump file should be stored} + {--prune : Delete all existing migration files}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Dump the given database schema'; + + /** + * Execute the console command. + * + * @param \Illuminate\Database\ConnectionResolverInterface $connections + * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher + * @return int + */ + public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher) + { + $connection = $connections->connection($database = $this->input->getOption('database')); + + $this->schemaState($connection)->dump( + $connection, $path = $this->path($connection) + ); + + $dispatcher->dispatch(new SchemaDumped($connection, $path)); + + $this->info('Database schema dumped successfully.'); + + if ($this->option('prune')) { + (new Filesystem)->deleteDirectory( + database_path('migrations'), $preserve = false + ); + + $this->info('Migrations pruned successfully.'); + } + } + + /** + * Create a schema state instance for the given connection. + * + * @param \Illuminate\Database\Connection $connection + * @return mixed + */ + protected function schemaState(Connection $connection) + { + return $connection->getSchemaState() + ->withMigrationTable($connection->getTablePrefix().Config::get('database.migrations', 'migrations')) + ->handleOutputUsing(function ($type, $buffer) { + $this->output->write($buffer); + }); + } + + /** + * Get the path that the dump should be written to. + * + * @param \Illuminate\Database\Connection $connection + */ + protected function path(Connection $connection) + { + return tap($this->option('path') ?: database_path('schema/'.$connection->getName().'-schema.dump'), function ($path) { + (new Filesystem)->ensureDirectoryExists(dirname($path)); + }); + } +} diff --git a/src/Illuminate/Database/Console/Factories/FactoryMakeCommand.php b/src/Illuminate/Database/Console/Factories/FactoryMakeCommand.php index 725a69ccceebbcd1dd213fe46358c5b8847c48ae..6233fe29f07ddd56a490a3e53f24a6b10a9ef4f8 100644 --- a/src/Illuminate/Database/Console/Factories/FactoryMakeCommand.php +++ b/src/Illuminate/Database/Console/Factories/FactoryMakeCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Console\Factories; use Illuminate\Console\GeneratorCommand; +use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; class FactoryMakeCommand extends GeneratorCommand @@ -35,7 +36,20 @@ class FactoryMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/factory.stub'; + return $this->resolveStubPath('/stubs/factory.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -46,22 +60,34 @@ class FactoryMakeCommand extends GeneratorCommand */ protected function buildClass($name) { + $factory = class_basename(Str::ucfirst(str_replace('Factory', '', $name))); + $namespaceModel = $this->option('model') - ? $this->qualifyClass($this->option('model')) - : trim($this->rootNamespace(), '\\').'\\Model'; + ? $this->qualifyModel($this->option('model')) + : $this->qualifyModel($this->guessModelName($name)); $model = class_basename($namespaceModel); + if (Str::startsWith($namespaceModel, $this->rootNamespace().'Models')) { + $namespace = Str::beforeLast('Database\\Factories\\'.Str::after($namespaceModel, $this->rootNamespace().'Models\\'), '\\'); + } else { + $namespace = 'Database\\Factories'; + } + + $replace = [ + '{{ factoryNamespace }}' => $namespace, + 'NamespacedDummyModel' => $namespaceModel, + '{{ namespacedModel }}' => $namespaceModel, + '{{namespacedModel}}' => $namespaceModel, + 'DummyModel' => $model, + '{{ model }}' => $model, + '{{model}}' => $model, + '{{ factory }}' => $factory, + '{{factory}}' => $factory, + ]; + return str_replace( - [ - 'NamespacedDummyModel', - 'DummyModel', - ], - [ - $namespaceModel, - $model, - ], - parent::buildClass($name) + array_keys($replace), array_values($replace), parent::buildClass($name) ); } @@ -73,11 +99,34 @@ class FactoryMakeCommand extends GeneratorCommand */ protected function getPath($name) { - $name = str_replace( - ['\\', '/'], '', $this->argument('name') - ); + $name = (string) Str::of($name)->replaceFirst($this->rootNamespace(), '')->finish('Factory'); + + return $this->laravel->databasePath().'/factories/'.str_replace('\\', '/', $name).'.php'; + } + + /** + * Guess the model name from the Factory name or return a default model name. + * + * @param string $name + * @return string + */ + protected function guessModelName($name) + { + if (Str::endsWith($name, 'Factory')) { + $name = substr($name, 0, -7); + } + + $modelName = $this->qualifyModel(Str::after($name, $this->rootNamespace())); + + if (class_exists($modelName)) { + return $modelName; + } + + if (is_dir(app_path('Models/'))) { + return $this->rootNamespace().'Models\Model'; + } - return $this->laravel->databasePath()."/factories/{$name}.php"; + return $this->rootNamespace().'Model'; } /** diff --git a/src/Illuminate/Database/Console/Factories/stubs/factory.stub b/src/Illuminate/Database/Console/Factories/stubs/factory.stub index 74ac7c52612128c8242a7fc8ace8aabcc66099ac..3e00f3659d3a1b9c58ca6fcc3085bbb7c2e5eb33 100644 --- a/src/Illuminate/Database/Console/Factories/stubs/factory.stub +++ b/src/Illuminate/Database/Console/Factories/stubs/factory.stub @@ -1,12 +1,20 @@ <?php -/** @var \Illuminate\Database\Eloquent\Factory $factory */ +namespace {{ factoryNamespace }}; -use Faker\Generator as Faker; -use NamespacedDummyModel; +use Illuminate\Database\Eloquent\Factories\Factory; -$factory->define(DummyModel::class, function (Faker $faker) { - return [ - // - ]; -}); +class {{ factory }}Factory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + // + ]; + } +} diff --git a/src/Illuminate/Database/Console/Migrations/FreshCommand.php b/src/Illuminate/Database/Console/Migrations/FreshCommand.php index 4bcba28a5da34e069143945587defb45389f59f4..7bfba0d78821638a1f31a5abecf3f01d3f4d1142 100644 --- a/src/Illuminate/Database/Console/Migrations/FreshCommand.php +++ b/src/Illuminate/Database/Console/Migrations/FreshCommand.php @@ -4,6 +4,8 @@ namespace Illuminate\Database\Console\Migrations; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Events\DatabaseRefreshed; use Symfony\Component\Console\Input\InputOption; class FreshCommand extends Command @@ -27,12 +29,12 @@ class FreshCommand extends Command /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } $database = $this->input->getOption('database'); @@ -48,13 +50,22 @@ class FreshCommand extends Command '--database' => $database, '--path' => $this->input->getOption('path'), '--realpath' => $this->input->getOption('realpath'), + '--schema-path' => $this->input->getOption('schema-path'), '--force' => true, '--step' => $this->option('step'), ])); + if ($this->laravel->bound(Dispatcher::class)) { + $this->laravel[Dispatcher::class]->dispatch( + new DatabaseRefreshed + ); + } + if ($this->needsSeeding()) { $this->runSeeder($database); } + + return 0; } /** @@ -77,7 +88,7 @@ class FreshCommand extends Command { $this->call('db:seed', array_filter([ '--database' => $database, - '--class' => $this->option('seeder') ?: 'DatabaseSeeder', + '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder', '--force' => true, ])); } @@ -96,6 +107,7 @@ class FreshCommand extends Command ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to be executed'], ['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'], + ['schema-path', null, InputOption::VALUE_OPTIONAL, 'The path to a schema dump file'], ['seed', null, InputOption::VALUE_NONE, 'Indicates if the seed task should be re-run'], ['seeder', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder'], ['step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually'], diff --git a/src/Illuminate/Database/Console/Migrations/MigrateCommand.php b/src/Illuminate/Database/Console/Migrations/MigrateCommand.php index 9fa978d0fabc6484b1772085bf4eb6a19e17ca3c..ea379e3f6d283223625f382e8145366cb593760c 100755 --- a/src/Illuminate/Database/Console/Migrations/MigrateCommand.php +++ b/src/Illuminate/Database/Console/Migrations/MigrateCommand.php @@ -3,7 +3,10 @@ namespace Illuminate\Database\Console\Migrations; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Events\SchemaLoaded; use Illuminate\Database\Migrations\Migrator; +use Illuminate\Database\SqlServerConnection; class MigrateCommand extends BaseCommand { @@ -18,8 +21,10 @@ class MigrateCommand extends BaseCommand {--force : Force the operation to run when in production} {--path=* : The path(s) to the migrations files to be executed} {--realpath : Indicate any provided migration file paths are pre-resolved absolute paths} + {--schema-path= : The path to a schema dump file} {--pretend : Dump the SQL queries that would be run} {--seed : Indicates if the seed task should be re-run} + {--seeder= : The class name of the root seeder} {--step : Force the migrations to be run so they can be rolled back individually}'; /** @@ -36,47 +41,63 @@ class MigrateCommand extends BaseCommand */ protected $migrator; + /** + * The event dispatcher instance. + * + * @var \Illuminate\Contracts\Events\Dispatcher + */ + protected $dispatcher; + /** * Create a new migration command instance. * * @param \Illuminate\Database\Migrations\Migrator $migrator + * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher * @return void */ - public function __construct(Migrator $migrator) + public function __construct(Migrator $migrator, Dispatcher $dispatcher) { parent::__construct(); $this->migrator = $migrator; + $this->dispatcher = $dispatcher; } /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } - $this->prepareDatabase(); + $this->migrator->usingConnection($this->option('database'), function () { + $this->prepareDatabase(); + + // Next, we will check to see if a path option has been defined. If it has + // we will use the path relative to the root of this installation folder + // so that migrations may be run for any path within the applications. + $this->migrator->setOutput($this->output) + ->run($this->getMigrationPaths(), [ + 'pretend' => $this->option('pretend'), + 'step' => $this->option('step'), + ]); - // Next, we will check to see if a path option has been defined. If it has - // we will use the path relative to the root of this installation folder - // so that migrations may be run for any path within the applications. - $this->migrator->setOutput($this->output) - ->run($this->getMigrationPaths(), [ - 'pretend' => $this->option('pretend'), - 'step' => $this->option('step'), + // Finally, if the "seed" option has been given, we will re-run the database + // seed task to re-populate the database, which is convenient when adding + // a migration and a seed at the same time, as it is only this command. + if ($this->option('seed') && ! $this->option('pretend')) { + $this->call('db:seed', [ + '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder', + '--force' => true, ]); + } + }); - // Finally, if the "seed" option has been given, we will re-run the database - // seed task to re-populate the database, which is convenient when adding - // a migration and a seed at the same time, as it is only this command. - if ($this->option('seed') && ! $this->option('pretend')) { - $this->call('db:seed', ['--force' => true]); - } + return 0; } /** @@ -86,12 +107,75 @@ class MigrateCommand extends BaseCommand */ protected function prepareDatabase() { - $this->migrator->setConnection($this->option('database')); - if (! $this->migrator->repositoryExists()) { $this->call('migrate:install', array_filter([ '--database' => $this->option('database'), ])); } + + if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) { + $this->loadSchemaState(); + } + } + + /** + * Load the schema state to seed the initial database schema structure. + * + * @return void + */ + protected function loadSchemaState() + { + $connection = $this->migrator->resolveConnection($this->option('database')); + + // First, we will make sure that the connection supports schema loading and that + // the schema file exists before we proceed any further. If not, we will just + // continue with the standard migration operation as normal without errors. + if ($connection instanceof SqlServerConnection || + ! is_file($path = $this->schemaPath($connection))) { + return; + } + + $this->line('<info>Loading stored database schema:</info> '.$path); + + $startTime = microtime(true); + + // Since the schema file will create the "migrations" table and reload it to its + // proper state, we need to delete it here so we don't get an error that this + // table already exists when the stored database schema file gets executed. + $this->migrator->deleteRepository(); + + $connection->getSchemaState()->handleOutputUsing(function ($type, $buffer) { + $this->output->write($buffer); + })->load($path); + + $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + + // Finally, we will fire an event that this schema has been loaded so developers + // can perform any post schema load tasks that are necessary in listeners for + // this event, which may seed the database tables with some necessary data. + $this->dispatcher->dispatch( + new SchemaLoaded($connection, $path) + ); + + $this->line('<info>Loaded stored database schema.</info> ('.$runTime.'ms)'); + } + + /** + * Get the path to the stored schema for the given connection. + * + * @param \Illuminate\Database\Connection $connection + * @return string + */ + protected function schemaPath($connection) + { + if ($this->option('schema-path')) { + return $this->option('schema-path'); + } + + if (file_exists($path = database_path('schema/'.$connection->getName().'-schema.dump'))) { + return $path; + } + + return database_path('schema/'.$connection->getName().'-schema.sql'); } } diff --git a/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php b/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php index 2c2a71155ff7223da17ab9e5fb801618620f589e..95c3a206e54a184b7d90269aaef85d4202072572 100644 --- a/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php +++ b/src/Illuminate/Database/Console/Migrations/MigrateMakeCommand.php @@ -132,14 +132,4 @@ class MigrateMakeCommand extends BaseCommand return parent::getMigrationPath(); } - - /** - * Determine if the given path(s) are pre-resolved "real" paths. - * - * @return bool - */ - protected function usingRealPath() - { - return $this->input->hasOption('realpath') && $this->option('realpath'); - } } diff --git a/src/Illuminate/Database/Console/Migrations/RefreshCommand.php b/src/Illuminate/Database/Console/Migrations/RefreshCommand.php index 1d8cb13673983ceaac16669d149fa83d1597cbae..2073cd9977e67c25fc75a1d626525ba2b5be8e9e 100755 --- a/src/Illuminate/Database/Console/Migrations/RefreshCommand.php +++ b/src/Illuminate/Database/Console/Migrations/RefreshCommand.php @@ -4,6 +4,8 @@ namespace Illuminate\Database\Console\Migrations; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Events\DatabaseRefreshed; use Symfony\Component\Console\Input\InputOption; class RefreshCommand extends Command @@ -27,12 +29,12 @@ class RefreshCommand extends Command /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } // Next we'll gather some of the options so that we can have the right options @@ -63,9 +65,17 @@ class RefreshCommand extends Command '--force' => true, ])); + if ($this->laravel->bound(Dispatcher::class)) { + $this->laravel[Dispatcher::class]->dispatch( + new DatabaseRefreshed + ); + } + if ($this->needsSeeding()) { $this->runSeeder($database); } + + return 0; } /** @@ -124,7 +134,7 @@ class RefreshCommand extends Command { $this->call('db:seed', array_filter([ '--database' => $database, - '--class' => $this->option('seeder') ?: 'DatabaseSeeder', + '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder', '--force' => true, ])); } diff --git a/src/Illuminate/Database/Console/Migrations/ResetCommand.php b/src/Illuminate/Database/Console/Migrations/ResetCommand.php index 28803806a33f918f151777346a427a9b54f9996b..1f2babbc8d082d9f9f3c1a4a691ca7f7be6021d5 100755 --- a/src/Illuminate/Database/Console/Migrations/ResetCommand.php +++ b/src/Illuminate/Database/Console/Migrations/ResetCommand.php @@ -47,26 +47,26 @@ class ResetCommand extends BaseCommand /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; - } - - $this->migrator->setConnection($this->option('database')); - - // First, we'll make sure that the migration table actually exists before we - // start trying to rollback and re-run all of the migrations. If it's not - // present we'll just bail out with an info message for the developers. - if (! $this->migrator->repositoryExists()) { - return $this->comment('Migration table not found.'); + return 1; } - $this->migrator->setOutput($this->output)->reset( - $this->getMigrationPaths(), $this->option('pretend') - ); + return $this->migrator->usingConnection($this->option('database'), function () { + // First, we'll make sure that the migration table actually exists before we + // start trying to rollback and re-run all of the migrations. If it's not + // present we'll just bail out with an info message for the developers. + if (! $this->migrator->repositoryExists()) { + return $this->comment('Migration table not found.'); + } + + $this->migrator->setOutput($this->output)->reset( + $this->getMigrationPaths(), $this->option('pretend') + ); + }); } /** diff --git a/src/Illuminate/Database/Console/Migrations/RollbackCommand.php b/src/Illuminate/Database/Console/Migrations/RollbackCommand.php index 65a50eb06ca760ef6c779a5d9f41e3350828e627..c851360f75245a2a68401ef72b16f505f4b6d1fb 100755 --- a/src/Illuminate/Database/Console/Migrations/RollbackCommand.php +++ b/src/Illuminate/Database/Console/Migrations/RollbackCommand.php @@ -47,22 +47,24 @@ class RollbackCommand extends BaseCommand /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } - $this->migrator->setConnection($this->option('database')); + $this->migrator->usingConnection($this->option('database'), function () { + $this->migrator->setOutput($this->output)->rollback( + $this->getMigrationPaths(), [ + 'pretend' => $this->option('pretend'), + 'step' => (int) $this->option('step'), + ] + ); + }); - $this->migrator->setOutput($this->output)->rollback( - $this->getMigrationPaths(), [ - 'pretend' => $this->option('pretend'), - 'step' => (int) $this->option('step'), - ] - ); + return 0; } /** diff --git a/src/Illuminate/Database/Console/Migrations/StatusCommand.php b/src/Illuminate/Database/Console/Migrations/StatusCommand.php index 034ea1beee4230d53404b1565ab7828a4ee0a165..2cf82f96f06b20578b224b5b13da410b561c172e 100644 --- a/src/Illuminate/Database/Console/Migrations/StatusCommand.php +++ b/src/Illuminate/Database/Console/Migrations/StatusCommand.php @@ -45,27 +45,27 @@ class StatusCommand extends BaseCommand /** * Execute the console command. * - * @return void + * @return int|null */ public function handle() { - $this->migrator->setConnection($this->option('database')); - - if (! $this->migrator->repositoryExists()) { - $this->error('Migration table not found.'); + return $this->migrator->usingConnection($this->option('database'), function () { + if (! $this->migrator->repositoryExists()) { + $this->error('Migration table not found.'); - return 1; - } + return 1; + } - $ran = $this->migrator->getRepository()->getRan(); + $ran = $this->migrator->getRepository()->getRan(); - $batches = $this->migrator->getRepository()->getMigrationBatches(); + $batches = $this->migrator->getRepository()->getMigrationBatches(); - if (count($migrations = $this->getStatusFor($ran, $batches)) > 0) { - $this->table(['Ran?', 'Migration', 'Batch'], $migrations); - } else { - $this->error('No migrations found'); - } + if (count($migrations = $this->getStatusFor($ran, $batches)) > 0) { + $this->table(['Ran?', 'Migration', 'Batch'], $migrations); + } else { + $this->error('No migrations found'); + } + }); } /** diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..b69aa86849d7fc937d2bf8e72b81f6c715115caa --- /dev/null +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -0,0 +1,165 @@ +<?php + +namespace Illuminate\Database\Console; + +use Illuminate\Console\Command; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Eloquent\MassPrunable; +use Illuminate\Database\Eloquent\Prunable; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Events\ModelsPruned; +use Illuminate\Support\Str; +use InvalidArgumentException; +use Symfony\Component\Finder\Finder; + +class PruneCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $signature = 'model:prune + {--model=* : Class names of the models to be pruned} + {--except=* : Class names of the models to be excluded from pruning} + {--chunk=1000 : The number of models to retrieve per chunk of models to be deleted} + {--pretend : Display the number of prunable records found instead of deleting them}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Prune models that are no longer needed'; + + /** + * Execute the console command. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return void + */ + public function handle(Dispatcher $events) + { + $models = $this->models(); + + if ($models->isEmpty()) { + $this->info('No prunable models found.'); + + return; + } + + if ($this->option('pretend')) { + $models->each(function ($model) { + $this->pretendToPrune($model); + }); + + return; + } + + $events->listen(ModelsPruned::class, function ($event) { + $this->info("{$event->count} [{$event->model}] records have been pruned."); + }); + + $models->each(function ($model) { + $instance = new $model; + + $chunkSize = property_exists($instance, 'prunableChunkSize') + ? $instance->prunableChunkSize + : $this->option('chunk'); + + $total = $this->isPrunable($model) + ? $instance->pruneAll($chunkSize) + : 0; + + if ($total == 0) { + $this->info("No prunable [$model] records found."); + } + }); + + $events->forget(ModelsPruned::class); + } + + /** + * Determine the models that should be pruned. + * + * @return \Illuminate\Support\Collection + */ + protected function models() + { + if (! empty($models = $this->option('model'))) { + return collect($models)->filter(function ($model) { + return class_exists($model); + })->values(); + } + + $except = $this->option('except'); + + if (! empty($models) && ! empty($except)) { + throw new InvalidArgumentException('The --models and --except options cannot be combined.'); + } + + return collect((new Finder)->in($this->getDefaultPath())->files()->name('*.php')) + ->map(function ($model) { + $namespace = $this->laravel->getNamespace(); + + return $namespace.str_replace( + ['/', '.php'], + ['\\', ''], + Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) + ); + })->when(! empty($except), function ($models) use ($except) { + return $models->reject(function ($model) use ($except) { + return in_array($model, $except); + }); + })->filter(function ($model) { + return $this->isPrunable($model); + })->filter(function ($model) { + return class_exists($model); + })->values(); + } + + /** + * Get the default path where models are located. + * + * @return string + */ + protected function getDefaultPath() + { + return app_path('Models'); + } + + /** + * Determine if the given model class is prunable. + * + * @param string $model + * @return bool + */ + protected function isPrunable($model) + { + $uses = class_uses_recursive($model); + + return in_array(Prunable::class, $uses) || in_array(MassPrunable::class, $uses); + } + + /** + * Display how many models will be pruned. + * + * @param string $model + * @return void + */ + protected function pretendToPrune($model) + { + $instance = new $model; + + $count = $instance->prunable() + ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($instance))), function ($query) { + $query->withTrashed(); + })->count(); + + if ($count === 0) { + $this->info("No prunable [$model] records found."); + } else { + $this->info("{$count} [{$model}] records will be pruned."); + } + } +} diff --git a/src/Illuminate/Database/Console/Seeds/SeedCommand.php b/src/Illuminate/Database/Console/Seeds/SeedCommand.php index 9f75a415571293519f0bd7cfd57d19cff65b6317..058e545c234f099c295d2acd8ac51ac7a275204a 100644 --- a/src/Illuminate/Database/Console/Seeds/SeedCommand.php +++ b/src/Illuminate/Database/Console/Seeds/SeedCommand.php @@ -6,6 +6,7 @@ use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; class SeedCommand extends Command @@ -49,21 +50,29 @@ class SeedCommand extends Command /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } + $previousConnection = $this->resolver->getDefaultConnection(); + $this->resolver->setDefaultConnection($this->getDatabase()); Model::unguarded(function () { $this->getSeeder()->__invoke(); }); + if ($previousConnection) { + $this->resolver->setDefaultConnection($previousConnection); + } + $this->info('Database seeding completed successfully.'); + + return 0; } /** @@ -73,9 +82,20 @@ class SeedCommand extends Command */ protected function getSeeder() { - $class = $this->laravel->make($this->input->getOption('class')); + $class = $this->input->getArgument('class') ?? $this->input->getOption('class'); - return $class->setContainer($this->laravel)->setCommand($this); + if (strpos($class, '\\') === false) { + $class = 'Database\\Seeders\\'.$class; + } + + if ($class === 'Database\\Seeders\\DatabaseSeeder' && + ! class_exists($class)) { + $class = 'DatabaseSeeder'; + } + + return $this->laravel->make($class) + ->setContainer($this->laravel) + ->setCommand($this); } /** @@ -90,6 +110,18 @@ class SeedCommand extends Command return $database ?: $this->laravel['config']['database.default']; } + /** + * Get the console command arguments. + * + * @return array + */ + protected function getArguments() + { + return [ + ['class', InputArgument::OPTIONAL, 'The class name of the root seeder', null], + ]; + } + /** * Get the console command options. * @@ -98,10 +130,8 @@ class SeedCommand extends Command protected function getOptions() { return [ - ['class', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder', 'DatabaseSeeder'], - + ['class', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder', 'Database\\Seeders\\DatabaseSeeder'], ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to seed'], - ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], ]; } diff --git a/src/Illuminate/Database/Console/Seeds/SeederMakeCommand.php b/src/Illuminate/Database/Console/Seeds/SeederMakeCommand.php index f25c9ab81fed6722b9607bb679a679d00a7c5758..aef7a77e6b1df54871dfa60a2ae098991566297d 100644 --- a/src/Illuminate/Database/Console/Seeds/SeederMakeCommand.php +++ b/src/Illuminate/Database/Console/Seeds/SeederMakeCommand.php @@ -3,8 +3,6 @@ namespace Illuminate\Database\Console\Seeds; use Illuminate\Console\GeneratorCommand; -use Illuminate\Filesystem\Filesystem; -use Illuminate\Support\Composer; class SeederMakeCommand extends GeneratorCommand { @@ -30,46 +28,36 @@ class SeederMakeCommand extends GeneratorCommand protected $type = 'Seeder'; /** - * The Composer instance. - * - * @var \Illuminate\Support\Composer - */ - protected $composer; - - /** - * Create a new command instance. + * Execute the console command. * - * @param \Illuminate\Filesystem\Filesystem $files - * @param \Illuminate\Support\Composer $composer * @return void */ - public function __construct(Filesystem $files, Composer $composer) + public function handle() { - parent::__construct($files); - - $this->composer = $composer; + parent::handle(); } /** - * Execute the console command. + * Get the stub file for the generator. * - * @return void + * @return string */ - public function handle() + protected function getStub() { - parent::handle(); - - $this->composer->dumpAutoloads(); + return $this->resolveStubPath('/stubs/seeder.stub'); } /** - * Get the stub file for the generator. + * Resolve the fully-qualified path to the stub. * + * @param string $stub * @return string */ - protected function getStub() + protected function resolveStubPath($stub) { - return __DIR__.'/stubs/seeder.stub'; + return is_file($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -80,7 +68,11 @@ class SeederMakeCommand extends GeneratorCommand */ protected function getPath($name) { - return $this->laravel->databasePath().'/seeds/'.$name.'.php'; + if (is_dir($this->laravel->databasePath().'/seeds')) { + return $this->laravel->databasePath().'/seeds/'.$name.'.php'; + } else { + return $this->laravel->databasePath().'/seeders/'.$name.'.php'; + } } /** diff --git a/src/Illuminate/Database/Console/Seeds/stubs/seeder.stub b/src/Illuminate/Database/Console/Seeds/stubs/seeder.stub index 4aa38454220c41f34a38f7d9e58baf0afb4dd973..5662969c70d964378685aa69b5b5855ab6c34b6a 100644 --- a/src/Illuminate/Database/Console/Seeds/stubs/seeder.stub +++ b/src/Illuminate/Database/Console/Seeds/stubs/seeder.stub @@ -1,8 +1,10 @@ <?php +namespace Database\Seeders; + use Illuminate\Database\Seeder; -class DummyClass extends Seeder +class {{ class }} extends Seeder { /** * Run the database seeds. diff --git a/src/Illuminate/Database/Console/WipeCommand.php b/src/Illuminate/Database/Console/WipeCommand.php index 63256ea8c490b8bb5ea6a5736c49c1d105b6f1f2..30825ed7c56770064121a0c3c7ba479bb9cdb466 100644 --- a/src/Illuminate/Database/Console/WipeCommand.php +++ b/src/Illuminate/Database/Console/WipeCommand.php @@ -27,12 +27,12 @@ class WipeCommand extends Command /** * Execute the console command. * - * @return void + * @return int */ public function handle() { if (! $this->confirmToProceed()) { - return; + return 1; } $database = $this->input->getOption('database'); @@ -52,6 +52,8 @@ class WipeCommand extends Command $this->info('Dropped all types successfully.'); } + + return 0; } /** diff --git a/src/Illuminate/Database/DBAL/TimestampType.php b/src/Illuminate/Database/DBAL/TimestampType.php new file mode 100644 index 0000000000000000000000000000000000000000..1557f124eb47acf701b77d60db1a7932b0b1d65a --- /dev/null +++ b/src/Illuminate/Database/DBAL/TimestampType.php @@ -0,0 +1,110 @@ +<?php + +namespace Illuminate\Database\DBAL; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\PhpDateTimeMappingType; +use Doctrine\DBAL\Types\Type; +use RuntimeException; + +class TimestampType extends Type implements PhpDateTimeMappingType +{ + /** + * {@inheritdoc} + * + * @return string + */ + public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) + { + $name = $platform->getName(); + + switch ($name) { + case 'mysql': + case 'mysql2': + return $this->getMySqlPlatformSQLDeclaration($fieldDeclaration); + + case 'postgresql': + case 'pgsql': + case 'postgres': + return $this->getPostgresPlatformSQLDeclaration($fieldDeclaration); + + case 'mssql': + return $this->getSqlServerPlatformSQLDeclaration($fieldDeclaration); + + case 'sqlite': + case 'sqlite3': + return $this->getSQLitePlatformSQLDeclaration($fieldDeclaration); + + default: + throw new RuntimeException('Invalid platform: '.$name); + } + } + + /** + * Get the SQL declaration for MySQL. + * + * @param array $fieldDeclaration + * @return string + */ + protected function getMySqlPlatformSQLDeclaration(array $fieldDeclaration) + { + $columnType = 'TIMESTAMP'; + + if ($fieldDeclaration['precision']) { + $columnType = 'TIMESTAMP('.$fieldDeclaration['precision'].')'; + } + + $notNull = $fieldDeclaration['notnull'] ?? false; + + if (! $notNull) { + return $columnType.' NULL'; + } + + return $columnType; + } + + /** + * Get the SQL declaration for PostgreSQL. + * + * @param array $fieldDeclaration + * @return string + */ + protected function getPostgresPlatformSQLDeclaration(array $fieldDeclaration) + { + return 'TIMESTAMP('.(int) $fieldDeclaration['precision'].')'; + } + + /** + * Get the SQL declaration for SQL Server. + * + * @param array $fieldDeclaration + * @return string + */ + protected function getSqlServerPlatformSQLDeclaration(array $fieldDeclaration) + { + return $fieldDeclaration['precision'] ?? false + ? 'DATETIME2('.$fieldDeclaration['precision'].')' + : 'DATETIME'; + } + + /** + * Get the SQL declaration for SQLite. + * + * @param array $fieldDeclaration + * @return string + */ + protected function getSQLitePlatformSQLDeclaration(array $fieldDeclaration) + { + return 'DATETIME'; + } + + /** + * {@inheritdoc} + * + * @return string + */ + public function getName() + { + return 'timestamp'; + } +} diff --git a/src/Illuminate/Database/DatabaseManager.php b/src/Illuminate/Database/DatabaseManager.php index 386f072689cfa4deb8a7d875a86be1a58efaf7b3..cb823bfa3de3500b7c1407bfaba9e4016446494d 100755 --- a/src/Illuminate/Database/DatabaseManager.php +++ b/src/Illuminate/Database/DatabaseManager.php @@ -2,12 +2,14 @@ namespace Illuminate\Database; +use Doctrine\DBAL\Types\Type; use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Support\Arr; use Illuminate\Support\ConfigurationUrlParser; use Illuminate\Support\Str; use InvalidArgumentException; use PDO; +use RuntimeException; /** * @mixin \Illuminate\Database\Connection @@ -49,6 +51,13 @@ class DatabaseManager implements ConnectionResolverInterface */ protected $reconnector; + /** + * The custom Doctrine column types. + * + * @var array + */ + protected $doctrineTypes = []; + /** * Create a new database manager instance. * @@ -62,7 +71,7 @@ class DatabaseManager implements ConnectionResolverInterface $this->factory = $factory; $this->reconnector = function ($connection) { - $this->reconnect($connection->getName()); + $this->reconnect($connection->getNameWithReadWriteType()); }; } @@ -165,7 +174,7 @@ class DatabaseManager implements ConnectionResolverInterface */ protected function configure(Connection $connection, $type) { - $connection = $this->setPdoForType($connection, $type); + $connection = $this->setPdoForType($connection, $type)->setReadWriteType($type); // First we'll set the fetch mode and a few other dependencies of the database // connection. This method basically just configures and prepares it to get @@ -174,11 +183,17 @@ class DatabaseManager implements ConnectionResolverInterface $connection->setEventDispatcher($this->app['events']); } + if ($this->app->bound('db.transactions')) { + $connection->setTransactionManager($this->app['db.transactions']); + } + // Here we'll set a reconnector callback. This reconnector can be any callable // so we will set a Closure to reconnect from this manager with the name of // the connection, which will allow us to reconnect from the connections. $connection->setReconnector($this->reconnector); + $this->registerConfiguredDoctrineTypes($connection); + return $connection; } @@ -200,6 +215,49 @@ class DatabaseManager implements ConnectionResolverInterface return $connection; } + /** + * Register custom Doctrine types with the connection. + * + * @param \Illuminate\Database\Connection $connection + * @return void + */ + protected function registerConfiguredDoctrineTypes(Connection $connection): void + { + foreach ($this->app['config']->get('database.dbal.types', []) as $name => $class) { + $this->registerDoctrineType($class, $name, $name); + } + + foreach ($this->doctrineTypes as $name => [$type, $class]) { + $connection->registerDoctrineType($class, $name, $type); + } + } + + /** + * Register a custom Doctrine type. + * + * @param string $class + * @param string $name + * @param string $type + * @return void + * + * @throws \Doctrine\DBAL\DBALException + * @throws \RuntimeException + */ + public function registerDoctrineType(string $class, string $name, string $type): void + { + if (! class_exists('Doctrine\DBAL\Connection')) { + throw new RuntimeException( + 'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).' + ); + } + + if (! Type::hasType($name)) { + Type::addType($name, $class); + } + + $this->doctrineTypes[$name] = [$type, $class]; + } + /** * Disconnect from the given database and remove from local cache. * @@ -245,6 +303,24 @@ class DatabaseManager implements ConnectionResolverInterface return $this->refreshPdoConnections($name); } + /** + * Set the default database connection for the callback execution. + * + * @param string $name + * @param callable $callback + * @return mixed + */ + public function usingConnection($name, callable $callback) + { + $previousName = $this->getDefaultConnection(); + + $this->setDefaultConnection($name); + + return tap($callback(), function () use ($previousName) { + $this->setDefaultConnection($previousName); + }); + } + /** * Refresh the PDO connections on a given connection. * @@ -253,11 +329,15 @@ class DatabaseManager implements ConnectionResolverInterface */ protected function refreshPdoConnections($name) { - $fresh = $this->makeConnection($name); + [$database, $type] = $this->parseConnectionName($name); + + $fresh = $this->configure( + $this->makeConnection($database), $type + ); return $this->connections[$name] - ->setPdo($fresh->getRawPdo()) - ->setReadPdo($fresh->getRawReadPdo()); + ->setPdo($fresh->getRawPdo()) + ->setReadPdo($fresh->getRawReadPdo()); } /** @@ -337,6 +417,19 @@ class DatabaseManager implements ConnectionResolverInterface $this->reconnector = $reconnector; } + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + return $this; + } + /** * Dynamically pass methods to the default connection. * diff --git a/src/Illuminate/Database/DatabaseServiceProvider.php b/src/Illuminate/Database/DatabaseServiceProvider.php index 3008e5b6bfe5ecf82484a237dc6fece51f5b63cc..4b6521b7016b3f7c907fbeb9f895f4e07a2ab756 100755 --- a/src/Illuminate/Database/DatabaseServiceProvider.php +++ b/src/Illuminate/Database/DatabaseServiceProvider.php @@ -6,7 +6,6 @@ use Faker\Factory as FakerFactory; use Faker\Generator as FakerGenerator; use Illuminate\Contracts\Queue\EntityResolver; use Illuminate\Database\Connectors\ConnectionFactory; -use Illuminate\Database\Eloquent\Factory as EloquentFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\QueueEntityResolver; use Illuminate\Support\ServiceProvider; @@ -42,9 +41,7 @@ class DatabaseServiceProvider extends ServiceProvider Model::clearBootedModels(); $this->registerConnectionServices(); - $this->registerEloquentFactory(); - $this->registerQueueableEntityResolver(); } @@ -72,6 +69,10 @@ class DatabaseServiceProvider extends ServiceProvider $this->app->bind('db.connection', function ($app) { return $app['db']->connection(); }); + + $this->app->singleton('db.transactions', function ($app) { + return new DatabaseTransactionsManager; + }); } /** @@ -92,12 +93,6 @@ class DatabaseServiceProvider extends ServiceProvider return static::$fakers[$locale]; }); - - $this->app->singleton(EloquentFactory::class, function ($app) { - return EloquentFactory::construct( - $app->make(FakerGenerator::class), $this->app->databasePath('factories') - ); - }); } /** diff --git a/src/Illuminate/Database/DatabaseTransactionRecord.php b/src/Illuminate/Database/DatabaseTransactionRecord.php new file mode 100755 index 0000000000000000000000000000000000000000..3259552dcfbb2599a98acb0bc41a1b8daa1fbfe1 --- /dev/null +++ b/src/Illuminate/Database/DatabaseTransactionRecord.php @@ -0,0 +1,73 @@ +<?php + +namespace Illuminate\Database; + +class DatabaseTransactionRecord +{ + /** + * The name of the database connection. + * + * @var string + */ + public $connection; + + /** + * The transaction level. + * + * @var int + */ + public $level; + + /** + * The callbacks that should be executed after committing. + * + * @var array + */ + protected $callbacks = []; + + /** + * Create a new database transaction record instance. + * + * @param string $connection + * @param int $level + * @return void + */ + public function __construct($connection, $level) + { + $this->connection = $connection; + $this->level = $level; + } + + /** + * Register a callback to be executed after committing. + * + * @param callable $callback + * @return void + */ + public function addCallback($callback) + { + $this->callbacks[] = $callback; + } + + /** + * Execute all of the callbacks. + * + * @return void + */ + public function executeCallbacks() + { + foreach ($this->callbacks as $callback) { + call_user_func($callback); + } + } + + /** + * Get all of the callbacks. + * + * @return array + */ + public function getCallbacks() + { + return $this->callbacks; + } +} diff --git a/src/Illuminate/Database/DatabaseTransactionsManager.php b/src/Illuminate/Database/DatabaseTransactionsManager.php new file mode 100755 index 0000000000000000000000000000000000000000..add2f7c1a61d60184dcae406fa1cadd7aa50c369 --- /dev/null +++ b/src/Illuminate/Database/DatabaseTransactionsManager.php @@ -0,0 +1,96 @@ +<?php + +namespace Illuminate\Database; + +class DatabaseTransactionsManager +{ + /** + * All of the recorded transactions. + * + * @var \Illuminate\Support\Collection + */ + protected $transactions; + + /** + * Create a new database transactions manager instance. + * + * @return void + */ + public function __construct() + { + $this->transactions = collect(); + } + + /** + * Start a new database transaction. + * + * @param string $connection + * @param int $level + * @return void + */ + public function begin($connection, $level) + { + $this->transactions->push( + new DatabaseTransactionRecord($connection, $level) + ); + } + + /** + * Rollback the active database transaction. + * + * @param string $connection + * @param int $level + * @return void + */ + public function rollback($connection, $level) + { + $this->transactions = $this->transactions->reject(function ($transaction) use ($connection, $level) { + return $transaction->connection == $connection && + $transaction->level > $level; + })->values(); + } + + /** + * Commit the active database transaction. + * + * @param string $connection + * @return void + */ + public function commit($connection) + { + [$forThisConnection, $forOtherConnections] = $this->transactions->partition( + function ($transaction) use ($connection) { + return $transaction->connection == $connection; + } + ); + + $this->transactions = $forOtherConnections->values(); + + $forThisConnection->map->executeCallbacks(); + } + + /** + * Register a transaction callback. + * + * @param callable $callback + * @return void + */ + public function addCallback($callback) + { + if ($current = $this->transactions->last()) { + return $current->addCallback($callback); + } + + call_user_func($callback); + } + + /** + * Get all the transactions. + * + * @return \Illuminate\Support\Collection + */ + public function getTransactions() + { + return $this->transactions; + } +} diff --git a/src/Illuminate/Database/DetectsConcurrencyErrors.php b/src/Illuminate/Database/DetectsConcurrencyErrors.php index 4933aee74b8fefbfa78dbba96166044ea9b81b98..c6c66f43563e0de7e733162902d43be47bdda37b 100644 --- a/src/Illuminate/Database/DetectsConcurrencyErrors.php +++ b/src/Illuminate/Database/DetectsConcurrencyErrors.php @@ -2,21 +2,21 @@ namespace Illuminate\Database; -use Exception; use Illuminate\Support\Str; use PDOException; +use Throwable; trait DetectsConcurrencyErrors { /** * Determine if the given exception was caused by a concurrency error such as a deadlock or serialization failure. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - protected function causedByConcurrencyError(Exception $e) + protected function causedByConcurrencyError(Throwable $e) { - if ($e instanceof PDOException && $e->getCode() === '40001') { + if ($e instanceof PDOException && ($e->getCode() === 40001 || $e->getCode() === '40001')) { return true; } diff --git a/src/Illuminate/Database/DetectsLostConnections.php b/src/Illuminate/Database/DetectsLostConnections.php index 07630c590d5c5e5050f7191cc4cd6c52d0473ad7..16c86874832a4f2f57244cfe012fbf2197c25e24 100644 --- a/src/Illuminate/Database/DetectsLostConnections.php +++ b/src/Illuminate/Database/DetectsLostConnections.php @@ -49,6 +49,14 @@ trait DetectsLostConnections 'SQLSTATE[HY000] [2002] Connection timed out', 'SSL: Connection timed out', 'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.', + 'Temporary failure in name resolution', + 'SSL: Broken pipe', + 'SQLSTATE[08S01]: Communication link failure', + 'SQLSTATE[08006] [7] could not connect to server: Connection refused Is the server running on host', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: No route to host', + 'The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', + 'SQLSTATE[08006] [7] could not translate host name', + 'TCP Provider: Error code 0x274C', ]); } } diff --git a/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php b/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php new file mode 100644 index 0000000000000000000000000000000000000000..14be425afa435b0cdc159061347bb6082226bc62 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/BroadcastableModelEventOccurred.php @@ -0,0 +1,137 @@ +<?php + +namespace Illuminate\Database\Eloquent; + +use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PrivateChannel; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Queue\SerializesModels; + +class BroadcastableModelEventOccurred implements ShouldBroadcast +{ + use InteractsWithSockets, SerializesModels; + + /** + * The model instance corresponding to the event. + * + * @var \Illuminate\Database\Eloquent\Model + */ + public $model; + + /** + * The event name (created, updated, etc.). + * + * @var string + */ + protected $event; + + /** + * The channels that the event should be broadcast on. + * + * @var array + */ + protected $channels = []; + + /** + * The queue connection that should be used to queue the broadcast job. + * + * @var string + */ + public $connection; + + /** + * The queue that should be used to queue the broadcast job. + * + * @var string + */ + public $queue; + + /** + * Create a new event instance. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $event + * @return void + */ + public function __construct($model, $event) + { + $this->model = $model; + $this->event = $event; + } + + /** + * The channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn() + { + $channels = empty($this->channels) + ? ($this->model->broadcastOn($this->event) ?: []) + : $this->channels; + + return collect($channels)->map(function ($channel) { + return $channel instanceof Model ? new PrivateChannel($channel) : $channel; + })->all(); + } + + /** + * The name the event should broadcast as. + * + * @return string + */ + public function broadcastAs() + { + $default = class_basename($this->model).ucfirst($this->event); + + return method_exists($this->model, 'broadcastAs') + ? ($this->model->broadcastAs($this->event) ?: $default) + : $default; + } + + /** + * Get the data that should be sent with the broadcasted event. + * + * @return array|null + */ + public function broadcastWith() + { + return method_exists($this->model, 'broadcastWith') + ? $this->model->broadcastWith($this->event) + : null; + } + + /** + * Manually specify the channels the event should broadcast on. + * + * @param array $channels + * @return $this + */ + public function onChannels(array $channels) + { + $this->channels = $channels; + + return $this; + } + + /** + * Determine if the event should be broadcast synchronously. + * + * @return bool + */ + public function shouldBroadcastNow() + { + return $this->event === 'deleted' && + ! method_exists($this->model, 'bootSoftDeletes'); + } + + /** + * Get the event name. + * + * @return string + */ + public function event() + { + return $this->event; + } +} diff --git a/src/Illuminate/Database/Eloquent/BroadcastsEvents.php b/src/Illuminate/Database/Eloquent/BroadcastsEvents.php new file mode 100644 index 0000000000000000000000000000000000000000..79dc02d8aea738fcd218ab964b937563660719ad --- /dev/null +++ b/src/Illuminate/Database/Eloquent/BroadcastsEvents.php @@ -0,0 +1,197 @@ +<?php + +namespace Illuminate\Database\Eloquent; + +use Illuminate\Support\Arr; + +trait BroadcastsEvents +{ + /** + * Boot the event broadcasting trait. + * + * @return void + */ + public static function bootBroadcastsEvents() + { + static::created(function ($model) { + $model->broadcastCreated(); + }); + + static::updated(function ($model) { + $model->broadcastUpdated(); + }); + + if (method_exists(static::class, 'bootSoftDeletes')) { + static::softDeleted(function ($model) { + $model->broadcastTrashed(); + }); + + static::restored(function ($model) { + $model->broadcastRestored(); + }); + } + + static::deleted(function ($model) { + $model->broadcastDeleted(); + }); + } + + /** + * Broadcast that the model was created. + * + * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels + * @return \Illuminate\Broadcasting\PendingBroadcast + */ + public function broadcastCreated($channels = null) + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('created'), 'created', $channels + ); + } + + /** + * Broadcast that the model was updated. + * + * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels + * @return \Illuminate\Broadcasting\PendingBroadcast + */ + public function broadcastUpdated($channels = null) + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('updated'), 'updated', $channels + ); + } + + /** + * Broadcast that the model was trashed. + * + * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels + * @return \Illuminate\Broadcasting\PendingBroadcast + */ + public function broadcastTrashed($channels = null) + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('trashed'), 'trashed', $channels + ); + } + + /** + * Broadcast that the model was restored. + * + * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels + * @return \Illuminate\Broadcasting\PendingBroadcast + */ + public function broadcastRestored($channels = null) + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('restored'), 'restored', $channels + ); + } + + /** + * Broadcast that the model was deleted. + * + * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels + * @return \Illuminate\Broadcasting\PendingBroadcast + */ + public function broadcastDeleted($channels = null) + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('deleted'), 'deleted', $channels + ); + } + + /** + * Broadcast the given event instance if channels are configured for the model event. + * + * @param mixed $instance + * @param string $event + * @param mixed $channels + * @return \Illuminate\Broadcasting\PendingBroadcast|null + */ + protected function broadcastIfBroadcastChannelsExistForEvent($instance, $event, $channels = null) + { + if (! static::$isBroadcasting) { + return; + } + + if (! empty($this->broadcastOn($event)) || ! empty($channels)) { + return broadcast($instance->onChannels(Arr::wrap($channels))); + } + } + + /** + * Create a new broadcastable model event event. + * + * @param string $event + * @return mixed + */ + public function newBroadcastableModelEvent($event) + { + return tap($this->newBroadcastableEvent($event), function ($event) { + $event->connection = property_exists($this, 'broadcastConnection') + ? $this->broadcastConnection + : $this->broadcastConnection(); + + $event->queue = property_exists($this, 'broadcastQueue') + ? $this->broadcastQueue + : $this->broadcastQueue(); + + $event->afterCommit = property_exists($this, 'broadcastAfterCommit') + ? $this->broadcastAfterCommit + : $this->broadcastAfterCommit(); + }); + } + + /** + * Create a new broadcastable model event for the model. + * + * @param string $event + * @return \Illuminate\Database\Eloquent\BroadcastableModelEventOccurred + */ + protected function newBroadcastableEvent($event) + { + return new BroadcastableModelEventOccurred($this, $event); + } + + /** + * Get the channels that model events should broadcast on. + * + * @param string $event + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn($event) + { + return [$this]; + } + + /** + * Get the queue connection that should be used to broadcast model events. + * + * @return string|null + */ + public function broadcastConnection() + { + // + } + + /** + * Get the queue that should be used to broadcast model events. + * + * @return string|null + */ + public function broadcastQueue() + { + // + } + + /** + * Determine if the model event broadcast queued job should be dispatched after all transactions are committed. + * + * @return bool + */ + public function broadcastAfterCommit() + { + return false; + } +} diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 87ae422ef94101df9663c36a416318b8165db1d3..babd44b857e797c60cba9cca1ad427d23a271d0a 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -10,6 +10,7 @@ use Illuminate\Database\Concerns\BuildsQueries; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -24,7 +25,10 @@ use ReflectionMethod; */ class Builder { - use BuildsQueries, Concerns\QueriesRelationships, ForwardsCalls; + use Concerns\QueriesRelationships, ForwardsCalls; + use BuildsQueries { + sole as baseSole; + } /** * The base query builder instance. @@ -68,14 +72,42 @@ class Builder */ protected $onDelete; + /** + * The properties that should be returned from query builder. + * + * @var string[] + */ + protected $propertyPassthru = [ + 'from', + ]; + /** * The methods that should be returned from query builder. * - * @var array + * @var string[] */ protected $passthru = [ - 'insert', 'insertOrIgnore', 'insertGetId', 'insertUsing', 'getBindings', 'toSql', 'dump', 'dd', - 'exists', 'doesntExist', 'count', 'min', 'max', 'avg', 'average', 'sum', 'getConnection', + 'aggregate', + 'average', + 'avg', + 'count', + 'dd', + 'doesntExist', + 'dump', + 'exists', + 'explain', + 'getBindings', + 'getConnection', + 'getGrammar', + 'insert', + 'insertGetId', + 'insertOrIgnore', + 'insertUsing', + 'max', + 'min', + 'raw', + 'sum', + 'toSql', ]; /** @@ -188,6 +220,10 @@ class Builder */ public function whereKey($id) { + if ($id instanceof Model) { + $id = $id->getKey(); + } + if (is_array($id) || $id instanceof Arrayable) { $this->query->whereIn($this->model->getQualifiedKeyName(), $id); @@ -209,6 +245,10 @@ class Builder */ public function whereKeyNot($id) { + if ($id instanceof Model) { + $id = $id->getKey(); + } + if (is_array($id) || $id instanceof Arrayable) { $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id); @@ -233,7 +273,7 @@ class Builder */ public function where($column, $operator = null, $value = null, $boolean = 'and') { - if ($column instanceof Closure) { + if ($column instanceof Closure && is_null($operator)) { $column($query = $this->model->newQueryWithoutRelationships()); $this->query->addNestedWhereQuery($query->getQuery(), $boolean); @@ -251,7 +291,7 @@ class Builder * @param mixed $operator * @param mixed $value * @param string $boolean - * @return \Illuminate\Database\Eloquent\Model|static + * @return \Illuminate\Database\Eloquent\Model|static|null */ public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') { @@ -264,7 +304,7 @@ class Builder * @param \Closure|array|string|\Illuminate\Database\Query\Expression $column * @param mixed $operator * @param mixed $value - * @return \Illuminate\Database\Eloquent\Builder|static + * @return $this */ public function orWhere($column, $operator = null, $value = null) { @@ -319,8 +359,14 @@ class Builder { $instance = $this->newModelInstance(); - return $instance->newCollection(array_map(function ($item) use ($instance) { - return $instance->newFromBuilder($item); + return $instance->newCollection(array_map(function ($item) use ($items, $instance) { + $model = $instance->newFromBuilder($item); + + if (count($items) > 1) { + $model->preventsLazyLoading = Model::preventsLazyLoading(); + } + + return $model; }, $items)); } @@ -385,6 +431,8 @@ class Builder { $result = $this->find($id, $columns); + $id = $id instanceof Arrayable ? $id->toArray() : $id; + if (is_array($id)) { if (count($result) === count(array_unique($id))) { return $result; @@ -421,13 +469,13 @@ class Builder * @param array $values * @return \Illuminate\Database\Eloquent\Model|static */ - public function firstOrNew(array $attributes, array $values = []) + public function firstOrNew(array $attributes = [], array $values = []) { if (! is_null($instance = $this->where($attributes)->first())) { return $instance; } - return $this->newModelInstance($attributes + $values); + return $this->newModelInstance(array_merge($attributes, $values)); } /** @@ -437,13 +485,13 @@ class Builder * @param array $values * @return \Illuminate\Database\Eloquent\Model|static */ - public function firstOrCreate(array $attributes, array $values = []) + public function firstOrCreate(array $attributes = [], array $values = []) { if (! is_null($instance = $this->where($attributes)->first())) { return $instance; } - return tap($this->newModelInstance($attributes + $values), function ($instance) { + return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) { $instance->save(); }); } @@ -501,6 +549,24 @@ class Builder return $callback(); } + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Model + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function sole($columns = ['*']) + { + try { + return $this->baseSole($columns); + } catch (RecordsNotFoundException $exception) { + throw (new ModelNotFoundException)->setModel(get_class($this->model)); + } + } + /** * Get a single column's value from the first result of a query. * @@ -514,6 +580,19 @@ class Builder } } + /** + * Get a single column's value from the first result of the query or throw an exception. + * + * @param string|\Illuminate\Database\Query\Expression $column + * @return mixed + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function valueOrFail($column) + { + return $this->firstOrFail([$column])->{Str::afterLast($column, '.')}; + } + /** * Execute the query as a "select" statement. * @@ -762,6 +841,49 @@ class Builder ]); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param \Illuminate\Pagination\Cursor|string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $perPage = $perPage ?: $this->model->getPerPage(); + + return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + } + + /** + * Ensure the proper order by required for cursor pagination. + * + * @param bool $shouldReverse + * @return \Illuminate\Support\Collection + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + if (empty($this->query->orders) && empty($this->query->unionOrders)) { + $this->enforceOrderBy(); + } + + if ($shouldReverse) { + $this->query->orders = collect($this->query->orders)->map(function ($order) { + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + })->toArray(); + } + + if ($this->query->unionOrders) { + return collect($this->query->unionOrders); + } + + return collect($this->query->orders); + } + /** * Save a new model and return the instance. * @@ -789,7 +911,7 @@ class Builder } /** - * Update a record in the database. + * Update records in the database. * * @param array $values * @return int @@ -799,6 +921,35 @@ class Builder return $this->toBase()->update($this->addUpdatedAtColumn($values)); } + /** + * Insert new records or update the existing ones. + * + * @param array $values + * @param array|string $uniqueBy + * @param array|null $update + * @return int + */ + public function upsert(array $values, $uniqueBy, $update = null) + { + if (empty($values)) { + return 0; + } + + if (! is_array(reset($values))) { + $values = [$values]; + } + + if (is_null($update)) { + $update = array_keys(reset($values)); + } + + return $this->toBase()->upsert( + $this->addTimestampsToUpsertValues($values), + $uniqueBy, + $this->addUpdatedAtToUpsertColumns($update) + ); + } + /** * Increment a column's value by a given amount. * @@ -853,7 +1004,7 @@ class Builder $qualifiedColumn = end($segments).'.'.$column; - $values[$qualifiedColumn] = $values[$column]; + $values[$qualifiedColumn] = Arr::get($values, $qualifiedColumn, $values[$column]); unset($values[$column]); @@ -861,7 +1012,58 @@ class Builder } /** - * Delete a record from the database. + * Add timestamps to the inserted values. + * + * @param array $values + * @return array + */ + protected function addTimestampsToUpsertValues(array $values) + { + if (! $this->model->usesTimestamps()) { + return $values; + } + + $timestamp = $this->model->freshTimestampString(); + + $columns = array_filter([ + $this->model->getCreatedAtColumn(), + $this->model->getUpdatedAtColumn(), + ]); + + foreach ($columns as $column) { + foreach ($values as &$row) { + $row = array_merge([$column => $timestamp], $row); + } + } + + return $values; + } + + /** + * Add the "updated at" column to the updated columns. + * + * @param array $update + * @return array + */ + protected function addUpdatedAtToUpsertColumns(array $update) + { + if (! $this->model->usesTimestamps()) { + return $update; + } + + $column = $this->model->getUpdatedAtColumn(); + + if (! is_null($column) && + ! array_key_exists($column, $update) && + ! in_array($column, $update)) { + $update[] = $column; + } + + return $update; + } + + /** + * Delete records from the database. * * @return mixed */ @@ -897,6 +1099,17 @@ class Builder $this->onDelete = $callback; } + /** + * Determine if the given model has a scope. + * + * @param string $scope + * @return bool + */ + public function hasNamedScope($scope) + { + return $this->model && $this->model->hasNamedScope($scope); + } + /** * Call the given local model scopes. * @@ -918,9 +1131,8 @@ class Builder // Next we'll pass the scope callback to the callScope method which will take // care of grouping the "wheres" properly so the logical order doesn't get // messed up when adding scopes. Then we'll return back out the builder. - $builder = $builder->callScope( - [$this->model, 'scope'.ucfirst($scope)], - (array) $parameters + $builder = $builder->callNamedScope( + $scope, Arr::wrap($parameters) ); } @@ -972,7 +1184,7 @@ class Builder * @param array $parameters * @return mixed */ - protected function callScope(callable $scope, $parameters = []) + protected function callScope(callable $scope, array $parameters = []) { array_unshift($parameters, $this); @@ -993,6 +1205,20 @@ class Builder return $result; } + /** + * Apply the given named scope on the current builder instance. + * + * @param string $scope + * @param array $parameters + * @return mixed + */ + protected function callNamedScope($scope, array $parameters = []) + { + return $this->callScope(function (...$parameters) use ($scope) { + return $this->model->callNamedScope($scope, $parameters); + }, $parameters); + } + /** * Nest where conditions by slicing them at the given where count. * @@ -1060,12 +1286,17 @@ class Builder /** * Set the relationships that should be eager loaded. * - * @param mixed $relations + * @param string|array $relations + * @param string|\Closure|null $callback * @return $this */ - public function with($relations) + public function with($relations, $callback = null) { - $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations); + if ($callback instanceof Closure) { + $eagerLoad = $this->parseWithRelations([$relations => $callback]); + } else { + $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations); + } $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad); @@ -1087,6 +1318,19 @@ class Builder return $this; } + /** + * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. + * + * @param mixed $relations + * @return $this + */ + public function withOnly($relations) + { + $this->eagerLoad = []; + + return $this->with($relations); + } + /** * Create a new instance of the model being queried. * @@ -1111,9 +1355,9 @@ class Builder $results = []; foreach ($relations as $name => $constraints) { - // If the "name" value is a numeric key, we can assume that no - // constraints have been specified. We'll just put an empty - // Closure there, so that we can treat them all the same. + // If the "name" value is a numeric key, we can assume that no constraints + // have been specified. We will just put an empty Closure there so that + // we can treat these all the same while we are looping through them. if (is_numeric($name)) { $name = $constraints; @@ -1183,6 +1427,19 @@ class Builder return $results; } + /** + * Apply query-time casts to the model instance. + * + * @param array $casts + * @return $this + */ + public function withCasts($casts) + { + $this->model->mergeCasts($casts); + + return $this; + } + /** * Get the underlying query builder instance. * @@ -1285,6 +1542,17 @@ class Builder return $this->model->qualifyColumn($column); } + /** + * Qualify the given columns with the model's table. + * + * @param array|\Illuminate\Database\Query\Expression $columns + * @return array + */ + public function qualifyColumns($columns) + { + return $this->model->qualifyColumns($columns); + } + /** * Get the given macro by name. * @@ -1343,6 +1611,10 @@ class Builder return new HigherOrderBuilderProxy($this, $key); } + if (in_array($key, $this->propertyPassthru)) { + return $this->toBase()->{$key}; + } + throw new Exception("Property [{$key}] does not exist on the Eloquent builder instance."); } @@ -1377,8 +1649,8 @@ class Builder return $callable(...$parameters); } - if ($this->model !== null && method_exists($this->model, $scope = 'scope'.ucfirst($method))) { - return $this->callScope([$this->model, $scope], $parameters); + if ($this->hasNamedScope($method)) { + return $this->callNamedScope($method, $parameters); } if (in_array($method, $this->passthru)) { @@ -1446,6 +1718,16 @@ class Builder } } + /** + * Clone the Eloquent query builder. + * + * @return static + */ + public function clone() + { + return clone $this; + } + /** * Force a clone of the underlying query builder when cloning. * diff --git a/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php new file mode 100644 index 0000000000000000000000000000000000000000..6f1713c946ac40136658c01c698588269665a789 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/ArrayObject.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use ArrayObject as BaseArrayObject; +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; + +class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable +{ + /** + * Get a collection containing the underlying array. + * + * @return \Illuminate\Support\Collection + */ + public function collect() + { + return collect($this->getArrayCopy()); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return $this->getArrayCopy(); + } + + /** + * Get the array that should be JSON serialized. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->getArrayCopy(); + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php new file mode 100644 index 0000000000000000000000000000000000000000..db9a21b461ba20485b6c1b1a64d8b6a60bafd7e6 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; + +class AsArrayObject implements Castable +{ + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return object|string + */ + public static function castUsing(array $arguments) + { + return new class implements CastsAttributes + { + public function get($model, $key, $value, $attributes) + { + return isset($attributes[$key]) ? new ArrayObject(json_decode($attributes[$key], true)) : null; + } + + public function set($model, $key, $value, $attributes) + { + return [$key => json_encode($value)]; + } + + public function serialize($model, string $key, $value, array $attributes) + { + return $value->getArrayCopy(); + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..585b6cfc70129f2cd6188384fd9ea2bb66483f39 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php @@ -0,0 +1,32 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Support\Collection; + +class AsCollection implements Castable +{ + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return object|string + */ + public static function castUsing(array $arguments) + { + return new class implements CastsAttributes + { + public function get($model, $key, $value, $attributes) + { + return isset($attributes[$key]) ? new Collection(json_decode($attributes[$key], true)) : null; + } + + public function set($model, $key, $value, $attributes) + { + return [$key => json_encode($value)]; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php new file mode 100644 index 0000000000000000000000000000000000000000..cd65624650ec767a0d4188107ff2b6db4b701ef7 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedArrayObject.php @@ -0,0 +1,45 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Support\Facades\Crypt; + +class AsEncryptedArrayObject implements Castable +{ + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return object|string + */ + public static function castUsing(array $arguments) + { + return new class implements CastsAttributes + { + public function get($model, $key, $value, $attributes) + { + if (isset($attributes[$key])) { + return new ArrayObject(json_decode(Crypt::decryptString($attributes[$key]), true)); + } + + return null; + } + + public function set($model, $key, $value, $attributes) + { + if (! is_null($value)) { + return [$key => Crypt::encryptString(json_encode($value))]; + } + + return null; + } + + public function serialize($model, string $key, $value, array $attributes) + { + return ! is_null($value) ? $value->getArrayCopy() : null; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..4d9fee7ece8500df9587d06adca1a22f71ca08d4 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Crypt; + +class AsEncryptedCollection implements Castable +{ + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return object|string + */ + public static function castUsing(array $arguments) + { + return new class implements CastsAttributes + { + public function get($model, $key, $value, $attributes) + { + if (isset($attributes[$key])) { + return new Collection(json_decode(Crypt::decryptString($attributes[$key]), true)); + } + + return null; + } + + public function set($model, $key, $value, $attributes) + { + if (! is_null($value)) { + return [$key => Crypt::encryptString(json_encode($value))]; + } + + return null; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsStringable.php b/src/Illuminate/Database/Eloquent/Casts/AsStringable.php new file mode 100644 index 0000000000000000000000000000000000000000..912659f38d54eb54c03482964b209cd0be5db102 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsStringable.php @@ -0,0 +1,32 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Support\Str; + +class AsStringable implements Castable +{ + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return object|string + */ + public static function castUsing(array $arguments) + { + return new class implements CastsAttributes + { + public function get($model, $key, $value, $attributes) + { + return isset($value) ? Str::of($value) : null; + } + + public function set($model, $key, $value, $attributes) + { + return isset($value) ? (string) $value : null; + } + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/Attribute.php b/src/Illuminate/Database/Eloquent/Casts/Attribute.php new file mode 100644 index 0000000000000000000000000000000000000000..a21b97bb3d2a76909d3ea214bf8c67ab5ab885b5 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/Attribute.php @@ -0,0 +1,74 @@ +<?php + +namespace Illuminate\Database\Eloquent\Casts; + +class Attribute +{ + /** + * The attribute accessor. + * + * @var callable + */ + public $get; + + /** + * The attribute mutator. + * + * @var callable + */ + public $set; + + /** + * Indicates if caching of objects is enabled for this attribute. + * + * @var bool + */ + public $withObjectCaching = true; + + /** + * Create a new attribute accessor / mutator. + * + * @param callable|null $get + * @param callable|null $set + * @return void + */ + public function __construct(callable $get = null, callable $set = null) + { + $this->get = $get; + $this->set = $set; + } + + /** + * Create a new attribute accessor. + * + * @param callable $get + * @return static + */ + public static function get(callable $get) + { + return new static($get); + } + + /** + * Create a new attribute mutator. + * + * @param callable $set + * @return static + */ + public static function set(callable $set) + { + return new static(null, $set); + } + + /** + * Disable object caching for the attribute. + * + * @return static + */ + public function withoutObjectCaching() + { + $this->withObjectCaching = false; + + return $this; + } +} diff --git a/src/Illuminate/Database/Eloquent/Collection.php b/src/Illuminate/Database/Eloquent/Collection.php index 3737ed59666f798f1e053b818cadf2847bdd5f82..cdd972c37fc74f2b6c74ccf667bd79320e41fdca 100755 --- a/src/Illuminate/Database/Eloquent/Collection.php +++ b/src/Illuminate/Database/Eloquent/Collection.php @@ -64,12 +64,14 @@ class Collection extends BaseCollection implements QueueableCollection } /** - * Load a set of relationship counts onto the collection. + * Load a set of aggregations over relationship's column onto the collection. * * @param array|string $relations + * @param string $column + * @param string $function * @return $this */ - public function loadCount($relations) + public function loadAggregate($relations, $column, $function = null) { if ($this->isEmpty()) { return $this; @@ -78,23 +80,96 @@ class Collection extends BaseCollection implements QueueableCollection $models = $this->first()->newModelQuery() ->whereKey($this->modelKeys()) ->select($this->first()->getKeyName()) - ->withCount(...func_get_args()) - ->get(); + ->withAggregate($relations, $column, $function) + ->get() + ->keyBy($this->first()->getKeyName()); $attributes = Arr::except( array_keys($models->first()->getAttributes()), $models->first()->getKeyName() ); - $models->each(function ($model) use ($attributes) { - $this->find($model->getKey())->forceFill( - Arr::only($model->getAttributes(), $attributes) - )->syncOriginalAttributes($attributes); + $this->each(function ($model) use ($models, $attributes) { + $extraAttributes = Arr::only($models->get($model->getKey())->getAttributes(), $attributes); + + $model->forceFill($extraAttributes) + ->syncOriginalAttributes($attributes) + ->mergeCasts($models->get($model->getKey())->getCasts()); }); return $this; } + /** + * Load a set of relationship counts onto the collection. + * + * @param array|string $relations + * @return $this + */ + public function loadCount($relations) + { + return $this->loadAggregate($relations, '*', 'count'); + } + + /** + * Load a set of relationship's max column values onto the collection. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMax($relations, $column) + { + return $this->loadAggregate($relations, $column, 'max'); + } + + /** + * Load a set of relationship's min column values onto the collection. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMin($relations, $column) + { + return $this->loadAggregate($relations, $column, 'min'); + } + + /** + * Load a set of relationship's column summations onto the collection. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadSum($relations, $column) + { + return $this->loadAggregate($relations, $column, 'sum'); + } + + /** + * Load a set of relationship's average column values onto the collection. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadAvg($relations, $column) + { + return $this->loadAggregate($relations, $column, 'avg'); + } + + /** + * Load a set of related existences onto the collection. + * + * @param array|string $relations + * @return $this + */ + public function loadExists($relations) + { + return $this->loadAggregate($relations, '*', 'exists'); + } + /** * Load a set of relationships onto the collection if they are not already eager loaded. * @@ -159,7 +234,7 @@ class Collection extends BaseCollection implements QueueableCollection return; } - $models = $models->pluck($name); + $models = $models->pluck($name)->whereNotNull(); if ($models->first() instanceof BaseCollection) { $models = $models->collapse(); @@ -189,6 +264,27 @@ class Collection extends BaseCollection implements QueueableCollection return $this; } + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + $this->pluck($relation) + ->filter() + ->groupBy(function ($model) { + return get_class($model); + }) + ->each(function ($models, $className) use ($relations) { + static::make($models)->loadCount($relations[$className] ?? []); + }); + + return $this; + } + /** * Determine if a key exists in the collection. * @@ -295,9 +391,11 @@ class Collection extends BaseCollection implements QueueableCollection ->get() ->getDictionary(); - return $this->map(function ($model) use ($freshModels) { - return $model->exists && isset($freshModels[$model->getKey()]) - ? $freshModels[$model->getKey()] : null; + return $this->filter(function ($model) use ($freshModels) { + return $model->exists && isset($freshModels[$model->getKey()]); + }) + ->map(function ($model) use ($freshModels) { + return $freshModels[$model->getKey()]; }); } @@ -401,7 +499,7 @@ class Collection extends BaseCollection implements QueueableCollection */ public function makeHidden($attributes) { - return $this->each->addHidden($attributes); + return $this->each->makeHidden($attributes); } /** @@ -415,6 +513,17 @@ class Collection extends BaseCollection implements QueueableCollection return $this->each->makeVisible($attributes); } + /** + * Append an attribute across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function append($attributes) + { + return $this->each->append($attributes); + } + /** * Get a dictionary keyed by primary keys. * @@ -463,7 +572,7 @@ class Collection extends BaseCollection implements QueueableCollection /** * Zip the collection together with one or more arrays. * - * @param mixed ...$items + * @param mixed ...$items * @return \Illuminate\Support\Collection */ public function zip($items) @@ -583,9 +692,9 @@ class Collection extends BaseCollection implements QueueableCollection if (count($relations) === 0 || $relations === [[]]) { return []; } elseif (count($relations) === 1) { - return array_values($relations)[0]; + return reset($relations); } else { - return array_intersect(...$relations); + return array_intersect(...array_values($relations)); } } @@ -612,4 +721,30 @@ class Collection extends BaseCollection implements QueueableCollection return $connection; } + + /** + * Get the Eloquent query builder from the collection. + * + * @return \Illuminate\Database\Eloquent\Builder + * + * @throws \LogicException + */ + public function toQuery() + { + $model = $this->first(); + + if (! $model) { + throw new LogicException('Unable to create query for empty collection.'); + } + + $class = get_class($model); + + if ($this->filter(function ($model) use ($class) { + return ! $model instanceof $class; + })->isNotEmpty()) { + throw new LogicException('Unable to create query for collection with mixed types.'); + } + + return $model->newModelQuery()->whereKey($this->modelKeys()); + } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php index d663a3835547db4fbab143784696e847bee3afb4..3e85fb955e619f1ff2637f6932f94055966c4b25 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php @@ -9,14 +9,14 @@ trait GuardsAttributes /** * The attributes that are mass assignable. * - * @var array + * @var string[] */ protected $fillable = []; /** * The attributes that aren't mass assignable. * - * @var array + * @var string[]|bool */ protected $guarded = ['*']; @@ -57,6 +57,19 @@ trait GuardsAttributes return $this; } + /** + * Merge new fillable attributes with existing fillable attributes on the model. + * + * @param array $fillable + * @return $this + */ + public function mergeFillable(array $fillable) + { + $this->fillable = array_merge($this->fillable, $fillable); + + return $this; + } + /** * Get the guarded attributes for the model. * @@ -64,7 +77,9 @@ trait GuardsAttributes */ public function getGuarded() { - return $this->guarded; + return $this->guarded === false + ? [] + : $this->guarded; } /** @@ -80,6 +95,19 @@ trait GuardsAttributes return $this; } + /** + * Merge new guarded attributes with existing guarded attributes on the model. + * + * @param array $guarded + * @return $this + */ + public function mergeGuarded(array $guarded) + { + $this->guarded = array_merge($this->guarded, $guarded); + + return $this; + } + /** * Disable all mass assignable restrictions. * @@ -102,7 +130,7 @@ trait GuardsAttributes } /** - * Determine if current state is "unguarded". + * Determine if the current state is "unguarded". * * @return bool */ @@ -189,9 +217,14 @@ trait GuardsAttributes protected function isGuardableColumn($key) { if (! isset(static::$guardableColumns[get_class($this)])) { - static::$guardableColumns[get_class($this)] = $this->getConnection() + $columns = $this->getConnection() ->getSchemaBuilder() ->getColumnListing($this->getTable()); + + if (empty($columns)) { + return true; + } + static::$guardableColumns[get_class($this)] = $columns; } return in_array($key, static::$guardableColumns[get_class($this)]); diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index c5fa43a60955d33eef9d15ec1bf7958a07a056cb..95d07e75aaabf4e789cffc2d660571926be74907 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -2,17 +2,30 @@ namespace Illuminate\Database\Eloquent\Concerns; +use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use DateTimeInterface; +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Eloquent\Casts\AsArrayObject; +use Illuminate\Database\Eloquent\Casts\AsCollection; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\InvalidCastException; use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\LazyLoadingViolationException; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; +use InvalidArgumentException; use LogicException; +use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; trait HasAttributes { @@ -38,15 +51,64 @@ trait HasAttributes protected $changes = []; /** - * The attributes that should be cast to native types. + * The attributes that should be cast. * * @var array */ protected $casts = []; + /** + * The attributes that have been cast using custom classes. + * + * @var array + */ + protected $classCastCache = []; + + /** + * The attributes that have been cast using "Attribute" return type mutators. + * + * @var array + */ + protected $attributeCastCache = []; + + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * The attributes that should be mutated to dates. * + * @deprecated Use the "casts" property + * * @var array */ protected $dates = []; @@ -79,6 +141,34 @@ trait HasAttributes */ protected static $mutatorCache = []; + /** + * The cache of the "Attribute" return type marked mutated attributes for each class. + * + * @var array + */ + protected static $attributeMutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated, gettable attributes for each class. + * + * @var array + */ + protected static $getAttributeMutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated, settable attributes for each class. + * + * @var array + */ + protected static $setAttributeMutatorCache = []; + + /** + * The encrypter instance that is used to encrypt attributes. + * + * @var \Illuminate\Contracts\Encryption\Encrypter + */ + public static $encrypter; + /** * Convert the model's attributes to an array. * @@ -173,7 +263,8 @@ trait HasAttributes protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes) { foreach ($this->getCasts() as $key => $value) { - if (! array_key_exists($key, $attributes) || in_array($key, $mutatedAttributes)) { + if (! array_key_exists($key, $attributes) || + in_array($key, $mutatedAttributes)) { continue; } @@ -187,15 +278,28 @@ trait HasAttributes // If the attribute cast was a date or a datetime, we will serialize the date as // a string. This allows the developers to customize how dates are serialized // into an array without affecting how they are persisted into the storage. - if ($attributes[$key] && - ($value === 'date' || $value === 'datetime')) { + if ($attributes[$key] && in_array($value, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { $attributes[$key] = $this->serializeDate($attributes[$key]); } - if ($attributes[$key] && $this->isCustomDateTimeCast($value)) { + if ($attributes[$key] && ($this->isCustomDateTimeCast($value) || + $this->isImmutableCustomDateTimeCast($value))) { $attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]); } + if ($attributes[$key] && $attributes[$key] instanceof DateTimeInterface && + $this->isClassCastable($key)) { + $attributes[$key] = $this->serializeDate($attributes[$key]); + } + + if ($attributes[$key] && $this->isClassSerializable($key)) { + $attributes[$key] = $this->serializeClassCastableAttribute($key, $attributes[$key]); + } + + if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) { + $attributes[$key] = isset($attributes[$key]) ? $attributes[$key]->value : null; + } + if ($attributes[$key] instanceof Arrayable) { $attributes[$key] = $attributes[$key]->toArray(); } @@ -211,7 +315,7 @@ trait HasAttributes */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -319,7 +423,10 @@ trait HasAttributes // get the attribute's value. Otherwise, we will proceed as if the developers // are asking for a relationship's value. This covers both types of values. if (array_key_exists($key, $this->attributes) || - $this->hasGetMutator($key)) { + array_key_exists($key, $this->casts) || + $this->hasGetMutator($key) || + $this->hasAttributeMutator($key) || + $this->isClassCastable($key)) { return $this->getAttributeValue($key); } @@ -341,31 +448,7 @@ trait HasAttributes */ public function getAttributeValue($key) { - $value = $this->getAttributeFromArray($key); - - // If the attribute has a get mutator, we will call that then return what - // it returns as the value, which is useful for transforming values on - // retrieval from the model to a form that is more useful for usage. - if ($this->hasGetMutator($key)) { - return $this->mutateAttribute($key, $value); - } - - // If the attribute exists within the cast array, we will convert it to - // an appropriate native PHP type dependent upon the associated value - // given with the key in the pair. Dayle made this comment line up. - if ($this->hasCast($key)) { - return $this->castAttribute($key, $value); - } - - // If the attribute is listed as a date, we will convert it to a DateTime - // instance on retrieval, which makes it quite convenient to work with - // date fields without having to create a mutator for each property. - if (in_array($key, $this->getDates()) && - ! is_null($value)) { - return $this->asDateTime($value); - } - - return $value; + return $this->transformModelValue($key, $this->getAttributeFromArray($key)); } /** @@ -376,7 +459,7 @@ trait HasAttributes */ protected function getAttributeFromArray($key) { - return $this->attributes[$key] ?? null; + return $this->getAttributes()[$key] ?? null; } /** @@ -394,12 +477,53 @@ trait HasAttributes return $this->relations[$key]; } + if (! $this->isRelation($key)) { + return; + } + + if ($this->preventsLazyLoading) { + $this->handleLazyLoadingViolation($key); + } + // If the "attribute" exists as a method on the model, we will just assume // it is a relationship and will load and return results from the query // and hydrate the relationship's value on the "relationships" array. - if (method_exists($this, $key)) { - return $this->getRelationshipFromMethod($key); + return $this->getRelationshipFromMethod($key); + } + + /** + * Determine if the given key is a relationship method on the model. + * + * @param string $key + * @return bool + */ + public function isRelation($key) + { + if ($this->hasAttributeMutator($key)) { + return false; + } + + return method_exists($this, $key) || + (static::$relationResolvers[get_class($this)][$key] ?? null); + } + + /** + * Handle a lazy loading violation. + * + * @param string $key + * @return mixed + */ + protected function handleLazyLoadingViolation($key) + { + if (isset(static::$lazyLoadingViolationCallback)) { + return call_user_func(static::$lazyLoadingViolationCallback, $this, $key); + } + + if (! $this->exists || $this->wasRecentlyCreated) { + return; } + + throw new LazyLoadingViolationException($this, $key); } /** @@ -442,6 +566,48 @@ trait HasAttributes return method_exists($this, 'get'.Str::studly($key).'Attribute'); } + /** + * Determine if a "Attribute" return type marked mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + public function hasAttributeMutator($key) + { + if (isset(static::$attributeMutatorCache[get_class($this)][$key])) { + return static::$attributeMutatorCache[get_class($this)][$key]; + } + + if (! method_exists($this, $method = Str::camel($key))) { + return static::$attributeMutatorCache[get_class($this)][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$attributeMutatorCache[get_class($this)][$key] = $returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class; + } + + /** + * Determine if a "Attribute" return type marked get mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + public function hasAttributeGetMutator($key) + { + if (isset(static::$getAttributeMutatorCache[get_class($this)][$key])) { + return static::$getAttributeMutatorCache[get_class($this)][$key]; + } + + if (! $this->hasAttributeMutator($key)) { + return static::$getAttributeMutatorCache[get_class($this)][$key] = false; + } + + return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($this->{Str::camel($key)}()->get); + } + /** * Get the value of an attribute using its mutator. * @@ -454,6 +620,34 @@ trait HasAttributes return $this->{'get'.Str::studly($key).'Attribute'}($value); } + /** + * Get the value of an "Attribute" return type marked attribute using its mutator. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function mutateAttributeMarkedAttribute($key, $value) + { + if (isset($this->attributeCastCache[$key])) { + return $this->attributeCastCache[$key]; + } + + $attribute = $this->{Str::camel($key)}(); + + $value = call_user_func($attribute->get ?: function ($value) { + return $value; + }, $value, $this->attributes); + + if (! is_object($value) || ! $attribute->withObjectCaching) { + unset($this->attributeCastCache[$key]); + } else { + $this->attributeCastCache[$key] = $value; + } + + return $value; + } + /** * Get the value of an attribute using its mutator for array conversion. * @@ -463,11 +657,35 @@ trait HasAttributes */ protected function mutateAttributeForArray($key, $value) { - $value = $this->mutateAttribute($key, $value); + if ($this->isClassCastable($key)) { + $value = $this->getClassCastableAttributeValue($key, $value); + } elseif (isset(static::$getAttributeMutatorCache[get_class($this)][$key]) && + static::$getAttributeMutatorCache[get_class($this)][$key] === true) { + $value = $this->mutateAttributeMarkedAttribute($key, $value); + + $value = $value instanceof DateTimeInterface + ? $this->serializeDate($value) + : $value; + } else { + $value = $this->mutateAttribute($key, $value); + } return $value instanceof Arrayable ? $value->toArray() : $value; } + /** + * Merge new casts with existing casts on the model. + * + * @param array $casts + * @return $this + */ + public function mergeCasts($casts) + { + $this->casts = array_merge($this->casts, $casts); + + return $this; + } + /** * Cast an attribute to a native PHP type. * @@ -477,11 +695,22 @@ trait HasAttributes */ protected function castAttribute($key, $value) { - if (is_null($value)) { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { return $value; } - switch ($this->getCastType($key)) { + // If the key is one of the encrypted castable types, we'll first decrypt + // the value and update the cast type so we may leverage the following + // logic for casting this value to any additionally specified types. + if ($this->isEncryptedCastable($key)) { + $value = $this->fromEncryptedString($value); + + $castType = Str::after($castType, 'encrypted:'); + } + + switch ($castType) { case 'int': case 'integer': return (int) $value; @@ -508,11 +737,74 @@ trait HasAttributes case 'datetime': case 'custom_datetime': return $this->asDateTime($value); + case 'immutable_date': + return $this->asDate($value)->toImmutable(); + case 'immutable_custom_datetime': + case 'immutable_datetime': + return $this->asDateTime($value)->toImmutable(); case 'timestamp': return $this->asTimestamp($value); - default: - return $value; } + + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableAttributeValue($key, $value); + } + + if ($this->isClassCastable($key)) { + return $this->getClassCastableAttributeValue($key, $value); + } + + return $value; + } + + /** + * Cast the given attribute using a custom cast class. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function getClassCastableAttributeValue($key, $value) + { + if (isset($this->classCastCache[$key])) { + return $this->classCastCache[$key]; + } else { + $caster = $this->resolveCasterClass($key); + + $value = $caster instanceof CastsInboundAttributes + ? $value + : $caster->get($this, $key, $value, $this->attributes); + + if ($caster instanceof CastsInboundAttributes || ! is_object($value)) { + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + + return $value; + } + } + + /** + * Cast the given attribute to an enum. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function getEnumCastableAttributeValue($key, $value) + { + if (is_null($value)) { + return; + } + + $castType = $this->getCasts()[$key]; + + if ($value instanceof $castType) { + return $value; + } + + return $castType::from($value); } /** @@ -527,6 +819,10 @@ trait HasAttributes return 'custom_datetime'; } + if ($this->isImmutableCustomDateTimeCast($this->getCasts()[$key])) { + return 'immutable_custom_datetime'; + } + if ($this->isDecimalCast($this->getCasts()[$key])) { return 'decimal'; } @@ -534,6 +830,35 @@ trait HasAttributes return trim(strtolower($this->getCasts()[$key])); } + /** + * Increment or decrement the given attribute using the custom cast class. + * + * @param string $method + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function deviateClassCastableAttribute($method, $key, $value) + { + return $this->resolveCasterClass($key)->{$method}( + $this, $key, $value, $this->attributes + ); + } + + /** + * Serialize the given attribute using the custom cast class. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function serializeClassCastableAttribute($key, $value) + { + return $this->resolveCasterClass($key)->serialize( + $this, $key, $value, $this->attributes + ); + } + /** * Determine if the cast type is a custom date time cast. * @@ -546,6 +871,18 @@ trait HasAttributes strncmp($cast, 'datetime:', 9) === 0; } + /** + * Determine if the cast type is an immutable custom date time cast. + * + * @param string $cast + * @return bool + */ + protected function isImmutableCustomDateTimeCast($cast) + { + return strncmp($cast, 'immutable_date:', 15) === 0 || + strncmp($cast, 'immutable_datetime:', 19) === 0; + } + /** * Determine if the cast type is a decimal cast. * @@ -568,9 +905,11 @@ trait HasAttributes { // First we will check for the presence of a mutator for the set operation // which simply lets the developers tweak the attribute as it is set on - // the model, such as "json_encoding" an listing of data for storage. + // this model, such as "json_encoding" a listing of data for storage. if ($this->hasSetMutator($key)) { return $this->setMutatedAttributeValue($key, $value); + } elseif ($this->hasAttributeSetMutator($key)) { + return $this->setAttributeMarkedMutatedAttributeValue($key, $value); } // If an attribute is listed as a "date", we'll convert it from a DateTime @@ -580,7 +919,19 @@ trait HasAttributes $value = $this->fromDateTime($value); } - if ($this->isJsonCastable($key) && ! is_null($value)) { + if ($this->isEnumCastable($key)) { + $this->setEnumCastableAttribute($key, $value); + + return $this; + } + + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if (! is_null($value) && $this->isJsonCastable($key)) { $value = $this->castAttributeAsJson($key, $value); } @@ -591,6 +942,10 @@ trait HasAttributes return $this->fillJsonAttribute($key, $value); } + if (! is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + $this->attributes[$key] = $value; return $this; @@ -607,6 +962,32 @@ trait HasAttributes return method_exists($this, 'set'.Str::studly($key).'Attribute'); } + /** + * Determine if an "Attribute" return type marked set mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + public function hasAttributeSetMutator($key) + { + $class = get_class($this); + + if (isset(static::$setAttributeMutatorCache[$class][$key])) { + return static::$setAttributeMutatorCache[$class][$key]; + } + + if (! method_exists($this, $method = Str::camel($key))) { + return static::$setAttributeMutatorCache[$class][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$setAttributeMutatorCache[$class][$key] = $returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class && + is_callable($this->{$method}()->set); + } + /** * Set the value of an attribute using its mutator. * @@ -619,6 +1000,35 @@ trait HasAttributes return $this->{'set'.Str::studly($key).'Attribute'}($value); } + /** + * Set the value of a "Attribute" return type marked attribute using its mutator. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function setAttributeMarkedMutatedAttributeValue($key, $value) + { + $attribute = $this->{Str::camel($key)}(); + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, call_user_func($callback, $value, $this->attributes) + ) + ); + + if (! is_object($value) || ! $attribute->withObjectCaching) { + unset($this->attributeCastCache[$key]); + } else { + $this->attributeCastCache[$key] = $value; + } + } + /** * Determine if the given attribute is a date or date castable. * @@ -628,7 +1038,7 @@ trait HasAttributes protected function isDateAttribute($key) { return in_array($key, $this->getDates(), true) || - $this->isDateCastable($key); + $this->isDateCastable($key); } /** @@ -642,22 +1052,81 @@ trait HasAttributes { [$key, $path] = explode('->', $key, 2); - $this->attributes[$key] = $this->asJson($this->getArrayAttributeWithValue( + $value = $this->asJson($this->getArrayAttributeWithValue( $path, $key, $value )); + $this->attributes[$key] = $this->isEncryptedCastable($key) + ? $this->castAttributeAsEncryptedString($key, $value) + : $value; + return $this; } /** - * Get an array attribute with the given key and value set. + * Set the value of a class castable attribute. * - * @param string $path * @param string $key * @param mixed $value - * @return $this + * @return void */ - protected function getArrayAttributeWithValue($path, $key, $value) + protected function setClassCastableAttribute($key, $value) + { + $caster = $this->resolveCasterClass($key); + + if (is_null($value)) { + $this->attributes = array_merge($this->attributes, array_map( + function () { + }, + $this->normalizeCastClassResponse($key, $caster->set( + $this, $key, $this->{$key}, $this->attributes + )) + )); + } else { + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse($key, $caster->set( + $this, $key, $value, $this->attributes + )) + ); + } + + if ($caster instanceof CastsInboundAttributes || ! is_object($value)) { + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + } + + /** + * Set the value of an enum castable attribute. + * + * @param string $key + * @param \BackedEnum $value + * @return void + */ + protected function setEnumCastableAttribute($key, $value) + { + $enumClass = $this->getCasts()[$key]; + + if (! isset($value)) { + $this->attributes[$key] = null; + } elseif ($value instanceof $enumClass) { + $this->attributes[$key] = $value->value; + } else { + $this->attributes[$key] = $enumClass::from($value)->value; + } + } + + /** + * Get an array attribute with the given key and value set. + * + * @param string $path + * @param string $key + * @param mixed $value + * @return $this + */ + protected function getArrayAttributeWithValue($path, $key, $value) { return tap($this->getArrayAttributeByKey($key), function (&$array) use ($path, $value) { Arr::set($array, str_replace('->', '.', $path), $value); @@ -672,8 +1141,15 @@ trait HasAttributes */ protected function getArrayAttributeByKey($key) { - return isset($this->attributes[$key]) ? - $this->fromJson($this->attributes[$key]) : []; + if (! isset($this->attributes[$key])) { + return []; + } + + return $this->fromJson( + $this->isEncryptedCastable($key) + ? $this->fromEncryptedString($this->attributes[$key]) + : $this->attributes[$key] + ); } /** @@ -719,6 +1195,40 @@ trait HasAttributes return json_decode($value, ! $asObject); } + /** + * Decrypt the given encrypted string. + * + * @param string $value + * @return mixed + */ + public function fromEncryptedString($value) + { + return (static::$encrypter ?? Crypt::getFacadeRoot())->decrypt($value, false); + } + + /** + * Cast the given attribute to an encrypted string. + * + * @param string $key + * @param mixed $value + * @return string + */ + protected function castAttributeAsEncryptedString($key, $value) + { + return (static::$encrypter ?? Crypt::getFacadeRoot())->encrypt($value, false); + } + + /** + * Set the encrypter instance that will be used to encrypt attributes. + * + * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter + * @return void + */ + public static function encryptUsing($encrypter) + { + static::$encrypter = $encrypter; + } + /** * Decode the given float. * @@ -802,15 +1312,16 @@ trait HasAttributes $format = $this->getDateFormat(); - // https://bugs.php.net/bug.php?id=75577 - if (version_compare(PHP_VERSION, '7.3.0-dev', '<')) { - $format = str_replace('.v', '.u', $format); - } - // Finally, we will just assume this date is in the format used by default on // the database connection and use that format to create the Carbon object // that is returned back out to the developers after we convert it here. - return Date::createFromFormat($format, $value); + try { + $date = Date::createFromFormat($format, $value); + } catch (InvalidArgumentException $e) { + $date = false; + } + + return $date ?: Date::parse($value); } /** @@ -856,7 +1367,9 @@ trait HasAttributes */ protected function serializeDate(DateTimeInterface $date) { - return $date->format($this->getDateFormat()); + return $date instanceof \DateTimeImmutable ? + CarbonImmutable::instance($date)->toJSON() : + Carbon::instance($date)->toJSON(); } /** @@ -866,14 +1379,16 @@ trait HasAttributes */ public function getDates() { + if (! $this->usesTimestamps()) { + return $this->dates; + } + $defaults = [ $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), ]; - return $this->usesTimestamps() - ? array_unique(array_merge($this->dates, $defaults)) - : $this->dates; + return array_unique(array_merge($this->dates, $defaults)); } /** @@ -937,7 +1452,18 @@ trait HasAttributes */ protected function isDateCastable($key) { - return $this->hasCast($key, ['date', 'datetime']); + return $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); + } + + /** + * Determine whether a value is Date / DateTime custom-castable for inbound manipulation. + * + * @param string $key + * @return bool + */ + protected function isDateCastableWithCustomFormat($key) + { + return $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); } /** @@ -948,7 +1474,210 @@ trait HasAttributes */ protected function isJsonCastable($key) { - return $this->hasCast($key, ['array', 'json', 'object', 'collection']); + return $this->hasCast($key, ['array', 'json', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Determine whether a value is an encrypted castable for inbound manipulation. + * + * @param string $key + * @return bool + */ + protected function isEncryptedCastable($key) + { + return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Determine if the given key is cast using a custom class. + * + * @param string $key + * @return bool + * + * @throws \Illuminate\Database\Eloquent\InvalidCastException + */ + protected function isClassCastable($key) + { + if (! array_key_exists($key, $this->getCasts())) { + return false; + } + + $castType = $this->parseCasterClass($this->getCasts()[$key]); + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + if (class_exists($castType)) { + return true; + } + + throw new InvalidCastException($this->getModel(), $key, $castType); + } + + /** + * Determine if the given key is cast using an enum. + * + * @param string $key + * @return bool + */ + protected function isEnumCastable($key) + { + if (! array_key_exists($key, $this->getCasts())) { + return false; + } + + $castType = $this->getCasts()[$key]; + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + if (function_exists('enum_exists') && enum_exists($castType)) { + return true; + } + } + + /** + * Determine if the key is deviable using a custom class. + * + * @param string $key + * @return bool + * + * @throws \Illuminate\Database\Eloquent\InvalidCastException + */ + protected function isClassDeviable($key) + { + return $this->isClassCastable($key) && + method_exists($castType = $this->parseCasterClass($this->getCasts()[$key]), 'increment') && + method_exists($castType, 'decrement'); + } + + /** + * Determine if the key is serializable using a custom class. + * + * @param string $key + * @return bool + * + * @throws \Illuminate\Database\Eloquent\InvalidCastException + */ + protected function isClassSerializable($key) + { + return ! $this->isEnumCastable($key) && + $this->isClassCastable($key) && + method_exists($this->resolveCasterClass($key), 'serialize'); + } + + /** + * Resolve the custom caster class for a given key. + * + * @param string $key + * @return mixed + */ + protected function resolveCasterClass($key) + { + $castType = $this->getCasts()[$key]; + + $arguments = []; + + if (is_string($castType) && strpos($castType, ':') !== false) { + $segments = explode(':', $castType, 2); + + $castType = $segments[0]; + $arguments = explode(',', $segments[1]); + } + + if (is_subclass_of($castType, Castable::class)) { + $castType = $castType::castUsing($arguments); + } + + if (is_object($castType)) { + return $castType; + } + + return new $castType(...$arguments); + } + + /** + * Parse the given caster class, removing any arguments. + * + * @param string $class + * @return string + */ + protected function parseCasterClass($class) + { + return strpos($class, ':') === false + ? $class + : explode(':', $class, 2)[0]; + } + + /** + * Merge the cast class and attribute cast attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromCachedCasts() + { + $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromAttributeCasts(); + } + + /** + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromClassCasts() + { + foreach ($this->classCastCache as $key => $value) { + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromAttributeCasts() + { + foreach ($this->attributeCastCache as $key => $value) { + $attribute = $this->{Str::camel($key)}(); + + if ($attribute->get && ! $attribute->set) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, call_user_func($callback, $value, $this->attributes) + ) + ); + } + } + + /** + * Normalize the response from a custom class caster. + * + * @param string $key + * @param mixed $value + * @return array + */ + protected function normalizeCastClassResponse($key, $value) + { + return is_array($value) ? $value : [$key => $value]; } /** @@ -958,9 +1687,21 @@ trait HasAttributes */ public function getAttributes() { + $this->mergeAttributesFromCachedCasts(); + return $this->attributes; } + /** + * Get all of the current attributes on the model for an insert operation. + * + * @return array + */ + protected function getAttributesForInsert() + { + return $this->getAttributes(); + } + /** * Set the array of model attributes. No checking is done. * @@ -976,6 +1717,9 @@ trait HasAttributes $this->syncOriginal(); } + $this->classCastCache = []; + $this->attributeCastCache = []; + return $this; } @@ -987,6 +1731,40 @@ trait HasAttributes * @return mixed|array */ public function getOriginal($key = null, $default = null) + { + return (new static)->setRawAttributes( + $this->original, $sync = true + )->getOriginalWithoutRewindingModel($key, $default); + } + + /** + * Get the model's original attribute values. + * + * @param string|null $key + * @param mixed $default + * @return mixed|array + */ + protected function getOriginalWithoutRewindingModel($key = null, $default = null) + { + if ($key) { + return $this->transformModelValue( + $key, Arr::get($this->original, $key, $default) + ); + } + + return collect($this->original)->mapWithKeys(function ($value, $key) { + return [$key => $this->transformModelValue($key, $value)]; + })->all(); + } + + /** + * Get the model's raw original attribute values. + * + * @param string|null $key + * @param mixed $default + * @return mixed|array + */ + public function getRawOriginal($key = null, $default = null) { return Arr::get($this->original, $key, $default); } @@ -1015,7 +1793,7 @@ trait HasAttributes */ public function syncOriginal() { - $this->original = $this->attributes; + $this->original = $this->getAttributes(); return $this; } @@ -1041,8 +1819,10 @@ trait HasAttributes { $attributes = is_array($attributes) ? $attributes : func_get_args(); + $modelAttributes = $this->getAttributes(); + foreach ($attributes as $attribute) { - $this->original[$attribute] = $this->attributes[$attribute]; + $this->original[$attribute] = $modelAttributes[$attribute]; } return $this; @@ -1074,7 +1854,7 @@ trait HasAttributes } /** - * Determine if the model and all the given attribute(s) have remained the same. + * Determine if the model or all the given attribute(s) have remained the same. * * @param array|string|null $attributes * @return bool @@ -1126,7 +1906,7 @@ trait HasAttributes } /** - * Get the attributes that have been changed since last sync. + * Get the attributes that have been changed since the last sync. * * @return array */ @@ -1135,7 +1915,7 @@ trait HasAttributes $dirty = []; foreach ($this->getAttributes() as $key => $value) { - if (! $this->originalIsEquivalent($key, $value)) { + if (! $this->originalIsEquivalent($key)) { $dirty[$key] = $value; } } @@ -1157,40 +1937,78 @@ trait HasAttributes * Determine if the new and old values for a given key are equivalent. * * @param string $key - * @param mixed $current * @return bool */ - public function originalIsEquivalent($key, $current) + public function originalIsEquivalent($key) { if (! array_key_exists($key, $this->original)) { return false; } - $original = $this->getOriginal($key); + $attribute = Arr::get($this->attributes, $key); + $original = Arr::get($this->original, $key); - if ($current === $original) { + if ($attribute === $original) { return true; - } elseif (is_null($current)) { + } elseif (is_null($attribute)) { return false; - } elseif ($this->isDateAttribute($key)) { - return $this->fromDateTime($current) === - $this->fromDateTime($original); + } elseif ($this->isDateAttribute($key) || $this->isDateCastableWithCustomFormat($key)) { + return $this->fromDateTime($attribute) === + $this->fromDateTime($original); } elseif ($this->hasCast($key, ['object', 'collection'])) { - return $this->castAttribute($key, $current) == - $this->castAttribute($key, $original); + return $this->fromJson($attribute) === + $this->fromJson($original); } elseif ($this->hasCast($key, ['real', 'float', 'double'])) { - if (($current === null && $original !== null) || ($current !== null && $original === null)) { + if (($attribute === null && $original !== null) || ($attribute !== null && $original === null)) { return false; } - return abs($this->castAttribute($key, $current) - $this->castAttribute($key, $original)) < PHP_FLOAT_EPSILON * 4; - } elseif ($this->hasCast($key)) { - return $this->castAttribute($key, $current) === - $this->castAttribute($key, $original); + return abs($this->castAttribute($key, $attribute) - $this->castAttribute($key, $original)) < PHP_FLOAT_EPSILON * 4; + } elseif ($this->hasCast($key, static::$primitiveCastTypes)) { + return $this->castAttribute($key, $attribute) === + $this->castAttribute($key, $original); + } elseif ($this->isClassCastable($key) && in_array($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) { + return $this->fromJson($attribute) === $this->fromJson($original); } - return is_numeric($current) && is_numeric($original) - && strcmp((string) $current, (string) $original) === 0; + return is_numeric($attribute) && is_numeric($original) + && strcmp((string) $attribute, (string) $original) === 0; + } + + /** + * Transform a raw model value using mutators, casts, etc. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function transformModelValue($key, $value) + { + // If the attribute has a get mutator, we will call that then return what + // it returns as the value, which is useful for transforming values on + // retrieval from the model to a form that is more useful for usage. + if ($this->hasGetMutator($key)) { + return $this->mutateAttribute($key, $value); + } elseif ($this->hasAttributeGetMutator($key)) { + return $this->mutateAttributeMarkedAttribute($key, $value); + } + + // If the attribute exists within the cast array, we will convert it to + // an appropriate native PHP type dependent upon the associated value + // given with the key in the pair. Dayle made this comment line up. + if ($this->hasCast($key)) { + return $this->castAttribute($key, $value); + } + + // If the attribute is listed as a date, we will convert it to a DateTime + // instance on retrieval, which makes it quite convenient to work with + // date fields without having to create a mutator for each property. + if ($value !== null + && \in_array($key, $this->getDates(), false)) { + return $this->asDateTime($value); + } + + return $value; } /** @@ -1221,6 +2039,17 @@ trait HasAttributes return $this; } + /** + * Return whether the accessor attribute has been appended. + * + * @param string $attribute + * @return bool + */ + public function hasAppended($attribute) + { + return in_array($attribute, $this->appends); + } + /** * Get the mutated attributes for a given instance. * @@ -1245,9 +2074,17 @@ trait HasAttributes */ public static function cacheMutatedAttributes($class) { - static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) { - return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); - })->all(); + static::$getAttributeMutatorCache[$class] = + collect($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($class)) + ->mapWithKeys(function ($match) { + return [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]; + })->all(); + + static::$mutatorCache[$class] = collect(static::getMutatorMethods($class)) + ->merge($attributeMutatorMethods) + ->map(function ($match) { + return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); + })->all(); } /** @@ -1262,4 +2099,31 @@ trait HasAttributes return $matches[1]; } + + /** + * Get all of the "Attribute" return typed attribute mutator methods. + * + * @param mixed $class + * @return array + */ + protected static function getAttributeMarkedMutatorMethods($class) + { + $instance = is_object($class) ? $class : new $class; + + return collect((new ReflectionClass($instance))->getMethods())->filter(function ($method) use ($instance) { + $returnType = $method->getReturnType(); + + if ($returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class) { + $method->setAccessible(true); + + if (is_callable($method->invoke($instance)->get)) { + return true; + } + } + + return false; + })->map->name->values()->all(); + } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php index 0dc54308f39548e91fc41c8340c56a68de768287..eb6a970985e628aca2d2d405e34de9275e275c51 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php @@ -147,7 +147,7 @@ trait HasEvents * Register a model event with the dispatcher. * * @param string $event - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ protected static function registerModelEvent($event, $callback) @@ -230,7 +230,7 @@ trait HasEvents /** * Register a retrieved model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function retrieved($callback) @@ -241,7 +241,7 @@ trait HasEvents /** * Register a saving model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function saving($callback) @@ -252,7 +252,7 @@ trait HasEvents /** * Register a saved model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function saved($callback) @@ -263,7 +263,7 @@ trait HasEvents /** * Register an updating model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function updating($callback) @@ -274,7 +274,7 @@ trait HasEvents /** * Register an updated model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function updated($callback) @@ -285,7 +285,7 @@ trait HasEvents /** * Register a creating model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function creating($callback) @@ -296,7 +296,7 @@ trait HasEvents /** * Register a created model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function created($callback) @@ -307,7 +307,7 @@ trait HasEvents /** * Register a replicating model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function replicating($callback) @@ -318,7 +318,7 @@ trait HasEvents /** * Register a deleting model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function deleting($callback) @@ -329,7 +329,7 @@ trait HasEvents /** * Register a deleted model event with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|\Closure|string $callback * @return void */ public static function deleted($callback) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index c48bd22a54f41060faa2b1940a5f3f9c5f6ff732..a4612b462ecb66c910cacb27a42a1b97572966bd 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Eloquent\Concerns; +use Closure; +use Illuminate\Database\ClassMorphViolationException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -38,12 +40,34 @@ trait HasRelationships /** * The many to many relationship methods. * - * @var array + * @var string[] */ public static $manyMethods = [ 'belongsToMany', 'morphToMany', 'morphedByMany', ]; + /** + * The relation resolver callbacks. + * + * @var array + */ + protected static $relationResolvers = []; + + /** + * Define a dynamic relation resolver. + * + * @param string $name + * @param \Closure $callback + * @return void + */ + public static function resolveRelationUsing($name, Closure $callback) + { + static::$relationResolvers = array_replace_recursive( + static::$relationResolvers, + [static::class => [$name => $callback]] + ); + } + /** * Define a one-to-one relationship. * @@ -233,7 +257,7 @@ trait HasRelationships // If the type value is null it is probably safe to assume we're eager loading // the relationship. In this case we'll just pass in a dummy query where we // need to remove any eager loads that may already be defined on a model. - return is_null($class = $this->{$type}) || $class === '' + return is_null($class = $this->getAttributeFromArray($type)) || $class === '' ? $this->morphEagerTo($name, $type, $id, $ownerKey) : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); } @@ -369,8 +393,12 @@ trait HasRelationships $secondKey = $secondKey ?: $through->getForeignKey(); return $this->newHasManyThrough( - $this->newRelatedInstance($related)->newQuery(), $this, $through, - $firstKey, $secondKey, $localKey ?: $this->getKeyName(), + $this->newRelatedInstance($related)->newQuery(), + $this, + $through, + $firstKey, + $secondKey, + $localKey ?: $this->getKeyName(), $secondLocalKey ?: $through->getKeyName() ); } @@ -488,7 +516,7 @@ trait HasRelationships * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName + * @param string|null $relationName * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, @@ -655,7 +683,7 @@ trait HasRelationships */ public function touches($relation) { - return in_array($relation, $this->touches); + return in_array($relation, $this->getTouchedRelations()); } /** @@ -665,7 +693,7 @@ trait HasRelationships */ public function touchOwners() { - foreach ($this->touches as $relation) { + foreach ($this->getTouchedRelations() as $relation) { $this->$relation()->touch(); if ($this->$relation instanceof self) { @@ -704,6 +732,10 @@ trait HasRelationships return array_search(static::class, $morphMap, true); } + if (Relation::requiresMorphMap()) { + throw new ClassMorphViolationException($this); + } + return static::class; } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php b/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php index b9c049b36482ebce33027dedf5692d82b1d2cb6d..13ebd31744cde676b12df5e541ca345142f0a27e 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php @@ -34,7 +34,7 @@ trait HasTimestamps * * @return void */ - protected function updateTimestamps() + public function updateTimestamps() { $time = $this->freshTimestamp(); @@ -130,7 +130,7 @@ trait HasTimestamps /** * Get the fully qualified "created at" column. * - * @return string + * @return string|null */ public function getQualifiedCreatedAtColumn() { @@ -140,7 +140,7 @@ trait HasTimestamps /** * Get the fully qualified "updated at" column. * - * @return string + * @return string|null */ public function getQualifiedUpdatedAtColumn() { diff --git a/src/Illuminate/Database/Eloquent/Concerns/HidesAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HidesAttributes.php index 7bd9ef9344a2ce3f27f6e4bc3a6ebd938cb6d57c..065d48a8d0ffdb1a364b711ad334d7c3c502ebfd 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HidesAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HidesAttributes.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Eloquent\Concerns; +use Closure; + trait HidesAttributes { /** @@ -41,19 +43,6 @@ trait HidesAttributes return $this; } - /** - * Add hidden attributes for the model. - * - * @param array|string|null $attributes - * @return void - */ - public function addHidden($attributes = null) - { - $this->hidden = array_merge( - $this->hidden, is_array($attributes) ? $attributes : func_get_args() - ); - } - /** * Get the visible attributes for the model. * @@ -77,50 +66,61 @@ trait HidesAttributes return $this; } - /** - * Add visible attributes for the model. - * - * @param array|string|null $attributes - * @return void - */ - public function addVisible($attributes = null) - { - $this->visible = array_merge( - $this->visible, is_array($attributes) ? $attributes : func_get_args() - ); - } - /** * Make the given, typically hidden, attributes visible. * - * @param array|string $attributes + * @param array|string|null $attributes * @return $this */ public function makeVisible($attributes) { - $this->hidden = array_diff($this->hidden, (array) $attributes); + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + $this->hidden = array_diff($this->hidden, $attributes); if (! empty($this->visible)) { - $this->addVisible($attributes); + $this->visible = array_merge($this->visible, $attributes); } return $this; } + /** + * Make the given, typically hidden, attributes visible if the given truth test passes. + * + * @param bool|Closure $condition + * @param array|string|null $attributes + * @return $this + */ + public function makeVisibleIf($condition, $attributes) + { + return value($condition, $this) ? $this->makeVisible($attributes) : $this; + } + /** * Make the given, typically visible, attributes hidden. * - * @param array|string $attributes + * @param array|string|null $attributes * @return $this */ public function makeHidden($attributes) { - $attributes = (array) $attributes; - - $this->visible = array_diff($this->visible, $attributes); - - $this->hidden = array_unique(array_merge($this->hidden, $attributes)); + $this->hidden = array_merge( + $this->hidden, is_array($attributes) ? $attributes : func_get_args() + ); return $this; } + + /** + * Make the given, typically visible, attributes hidden if the given truth test passes. + * + * @param bool|Closure $condition + * @param array|string|null $attributes + * @return $this + */ + public function makeHiddenIf($condition, $attributes) + { + return value($condition, $this) ? $this->makeHidden($attributes) : $this; + } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php index f26154210ca6865472074fe167b5f3970ef0b145..c16af1fa0007de2e3e40c64c56022557f95ba346 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php @@ -2,14 +2,16 @@ namespace Illuminate\Database\Eloquent\Concerns; +use BadMethodCallException; use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Expression; use Illuminate\Support\Str; -use RuntimeException; trait QueriesRelationships { @@ -36,7 +38,7 @@ trait QueriesRelationships } if ($relation instanceof MorphTo) { - throw new RuntimeException('Please use whereHasMorph() for MorphTo relationships.'); + return $this->hasMorph($relation, ['*'], $operator, $count, $boolean, $callback); } // If we only need to check for the existence of the relation, then we can optimize @@ -152,7 +154,7 @@ trait QueriesRelationships * Add a relationship count / exists condition to the query with where clauses and an "or". * * @param string $relation - * @param \Closure $callback + * @param \Closure|null $callback * @param string $operator * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static @@ -178,7 +180,7 @@ trait QueriesRelationships * Add a relationship count / exists condition to the query with where clauses and an "or". * * @param string $relation - * @param \Closure $callback + * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ public function orWhereDoesntHave($relation, Closure $callback = null) @@ -189,7 +191,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query. * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @param string $operator * @param int $count @@ -199,16 +201,18 @@ trait QueriesRelationships */ public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) { - $relation = $this->getRelationWithoutConstraints($relation); + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } $types = (array) $types; if ($types === ['*']) { $types = $this->model->newModelQuery()->distinct()->pluck($relation->getMorphType())->filter()->all(); + } - foreach ($types as &$type) { - $type = Relation::getMorphedModel($type) ?? $type; - } + foreach ($types as &$type) { + $type = Relation::getMorphedModel($type) ?? $type; } return $this->where(function ($query) use ($relation, $callback, $operator, $count, $types) { @@ -222,7 +226,7 @@ trait QueriesRelationships }; } - $query->where($this->query->from.'.'.$relation->getMorphType(), '=', (new $type)->getMorphClass()) + $query->where($this->qualifyColumn($relation->getMorphType()), '=', (new $type)->getMorphClass()) ->whereHas($belongsTo, $callback, $operator, $count); }); } @@ -254,7 +258,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with an "or". * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @param string $operator * @param int $count @@ -268,7 +272,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query. * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @param string $boolean * @param \Closure|null $callback @@ -282,7 +286,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with an "or". * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @return \Illuminate\Database\Eloquent\Builder|static */ @@ -294,7 +298,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with where clauses. * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @param \Closure|null $callback * @param string $operator @@ -309,9 +313,9 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types - * @param \Closure $callback + * @param \Closure|null $callback * @param string $operator * @param int $count * @return \Illuminate\Database\Eloquent\Builder|static @@ -324,7 +328,7 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with where clauses. * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static @@ -337,9 +341,9 @@ trait QueriesRelationships /** * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation * @param string|array $types - * @param \Closure $callback + * @param \Closure|null $callback * @return \Illuminate\Database\Eloquent\Builder|static */ public function orWhereDoesntHaveMorph($relation, $types, Closure $callback = null) @@ -348,12 +352,171 @@ trait QueriesRelationships } /** - * Add subselect queries to count the relations. + * Add a basic where clause to a relationship query. + * + * @param string $relation + * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function whereRelation($relation, $column, $operator = null, $value = null) + { + return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @param string $relation + * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function orWhereRelation($relation, $column, $operator = null, $value = null) + { + return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with a where clause. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function whereMorphRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->whereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with an "or where" clause. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function orWhereMorphRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->orWhereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a morph-to relationship condition to the query. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param \Illuminate\Database\Eloquent\Model|string $model + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function whereMorphedTo($relation, $model, $boolean = 'and') + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + if (is_string($model)) { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array($model, $morphMap)) { + $model = array_search($model, $morphMap, true); + } + + return $this->where($relation->getMorphType(), $model, null, $boolean); + } + + return $this->where(function ($query) use ($relation, $model) { + $query->where($relation->getMorphType(), $model->getMorphClass()) + ->where($relation->getForeignKeyName(), $model->getKey()); + }, null, null, $boolean); + } + + /** + * Add a morph-to relationship condition to the query with an "or where" clause. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param \Illuminate\Database\Eloquent\Model|string $model + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function orWhereMorphedTo($relation, $model) + { + return $this->whereMorphedTo($relation, $model, 'or'); + } + + /** + * Add a "belongs to" relationship where clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model $related + * @param string $relationship + * @param string $boolean + * @return $this + * + * @throws \RuntimeException + */ + public function whereBelongsTo($related, $relationshipName = null, $boolean = 'and') + { + if ($relationshipName === null) { + $relationshipName = Str::camel(class_basename($related)); + } + + try { + $relationship = $this->model->{$relationshipName}(); + } catch (BadMethodCallException $exception) { + throw RelationNotFoundException::make($this->model, $relationshipName); + } + + if (! $relationship instanceof BelongsTo) { + throw RelationNotFoundException::make($this->model, $relationshipName, BelongsTo::class); + } + + $this->where( + $relationship->getQualifiedForeignKeyName(), + '=', + $related->getAttributeValue($relationship->getOwnerKeyName()), + $boolean, + ); + + return $this; + } + + /** + * Add an "BelongsTo" relationship with an "or where" clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model $related + * @param string $relationship + * @return $this + * + * @throws \RuntimeException + */ + public function orWhereBelongsTo($related, $relationshipName = null) + { + return $this->whereBelongsTo($related, $relationshipName, 'or'); + } + + /** + * Add subselect queries to include an aggregate value for a relationship. * * @param mixed $relations + * @param string $column + * @param string $function * @return $this */ - public function withCount($relations) + public function withAggregate($relations, $column, $function = null) { if (empty($relations)) { return $this; @@ -363,12 +526,12 @@ trait QueriesRelationships $this->query->select([$this->query->from.'.*']); } - $relations = is_array($relations) ? $relations : func_get_args(); + $relations = is_array($relations) ? $relations : [$relations]; foreach ($this->parseWithRelations($relations) as $name => $constraints) { // First we will determine if the name has been aliased using an "as" clause on the name // and if it has we will extract the actual relationship name and the desired name of - // the resulting column. This allows multiple counts on the same relationship name. + // the resulting column. This allows multiple aggregates on the same relationships. $segments = explode(' ', $name); unset($alias); @@ -379,34 +542,135 @@ trait QueriesRelationships $relation = $this->getRelationWithoutConstraints($name); - // Here we will get the relationship count query and prepare to add it to the main query + if ($function) { + $hashedColumn = $this->getQuery()->from === $relation->getQuery()->getQuery()->from + ? "{$relation->getRelationCountHash(false)}.$column" + : $column; + + $wrappedColumn = $this->getQuery()->getGrammar()->wrap( + $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) + ); + + $expression = $function === 'exists' ? $wrappedColumn : sprintf('%s(%s)', $function, $wrappedColumn); + } else { + $expression = $column; + } + + // Here, we will grab the relationship sub-query and prepare to add it to the main query // as a sub-select. First, we'll get the "has" query and use that to get the relation - // count query. We will normalize the relation name then append _count as the name. - $query = $relation->getRelationExistenceCountQuery( - $relation->getRelated()->newQuery(), $this - ); + // sub-query. We'll format this relationship name and append this column if needed. + $query = $relation->getRelationExistenceQuery( + $relation->getRelated()->newQuery(), $this, new Expression($expression) + )->setBindings([], 'select'); $query->callScope($constraints); $query = $query->mergeConstraintsFrom($relation->getQuery())->toBase(); + // If the query contains certain elements like orderings / more than one column selected + // then we will remove those elements from the query so that it will execute properly + // when given to the database. Otherwise, we may receive SQL errors or poor syntax. + $query->orders = null; + $query->setBindings([], 'order'); + if (count($query->columns) > 1) { $query->columns = [$query->columns[0]]; - $query->bindings['select'] = []; } - // Finally we will add the proper result column alias to the query and run the subselect - // statement against the query builder. Then we will return the builder instance back - // to the developer for further constraint chaining that needs to take place on it. - $column = $alias ?? Str::snake($name.'_count'); + // Finally, we will make the proper column alias to the query and run this sub-select on + // the query builder. Then, we will return the builder instance back to the developer + // for further constraint chaining that needs to take place on the query as needed. + $alias = $alias ?? Str::snake( + preg_replace('/[^[:alnum:][:space:]_]/u', '', "$name $function $column") + ); - $this->selectSub($query, $column); + if ($function === 'exists') { + $this->selectRaw( + sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)), + $query->getBindings() + )->withCasts([$alias => 'bool']); + } else { + $this->selectSub( + $function ? $query : $query->limit(1), + $alias + ); + } } return $this; } + /** + * Add subselect queries to count the relations. + * + * @param mixed $relations + * @return $this + */ + public function withCount($relations) + { + return $this->withAggregate(is_array($relations) ? $relations : func_get_args(), '*', 'count'); + } + + /** + * Add subselect queries to include the max of the relation's column. + * + * @param string|array $relation + * @param string $column + * @return $this + */ + public function withMax($relation, $column) + { + return $this->withAggregate($relation, $column, 'max'); + } + + /** + * Add subselect queries to include the min of the relation's column. + * + * @param string|array $relation + * @param string $column + * @return $this + */ + public function withMin($relation, $column) + { + return $this->withAggregate($relation, $column, 'min'); + } + + /** + * Add subselect queries to include the sum of the relation's column. + * + * @param string|array $relation + * @param string $column + * @return $this + */ + public function withSum($relation, $column) + { + return $this->withAggregate($relation, $column, 'sum'); + } + + /** + * Add subselect queries to include the average of the relation's column. + * + * @param string|array $relation + * @param string $column + * @return $this + */ + public function withAvg($relation, $column) + { + return $this->withAggregate($relation, $column, 'avg'); + } + + /** + * Add subselect queries to include the existence of related models. + * + * @param string|array $relation + * @return $this + */ + public function withExists($relation) + { + return $this->withAggregate($relation, '*', 'exists'); + } + /** * Add the "has" condition where clause to the query. * diff --git a/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php b/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php new file mode 100644 index 0000000000000000000000000000000000000000..e0c42c4c642b1c0b177040f21ae383cf034a802b --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/BelongsToManyRelationship.php @@ -0,0 +1,61 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; + +class BelongsToManyRelationship +{ + /** + * The related factory instance. + * + * @var \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model + */ + protected $factory; + + /** + * The pivot attributes / attribute resolver. + * + * @var callable|array + */ + protected $pivot; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * Create a new attached relationship definition. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model $factory + * @param callable|array $pivot + * @param string $relationship + * @return void + */ + public function __construct($factory, $pivot, $relationship) + { + $this->factory = $factory; + $this->pivot = $pivot; + $this->relationship = $relationship; + } + + /** + * Create the attached relationship for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + public function createFor(Model $model) + { + Collection::wrap($this->factory instanceof Factory ? $this->factory->create([], $model) : $this->factory)->each(function ($attachable) use ($model) { + $model->{$this->relationship}()->attach( + $attachable, + is_callable($this->pivot) ? call_user_func($this->pivot, $model) : $this->pivot + ); + }); + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php b/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php new file mode 100644 index 0000000000000000000000000000000000000000..55747fdc64880328b0bc6888bda1d5915451699a --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/BelongsToRelationship.php @@ -0,0 +1,80 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +class BelongsToRelationship +{ + /** + * The related factory instance. + * + * @var \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Database\Eloquent\Model + */ + protected $factory; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * The cached, resolved parent instance ID. + * + * @var mixed + */ + protected $resolved; + + /** + * Create a new "belongs to" relationship definition. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Database\Eloquent\Model $factory + * @param string $relationship + * @return void + */ + public function __construct($factory, $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; + } + + /** + * Get the parent model attributes and resolvers for the given child model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return array + */ + public function attributesFor(Model $model) + { + $relationship = $model->{$this->relationship}(); + + return $relationship instanceof MorphTo ? [ + $relationship->getMorphType() => $this->factory instanceof Factory ? $this->factory->newModel()->getMorphClass() : $this->factory->getMorphClass(), + $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + ] : [ + $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + ]; + } + + /** + * Get the deferred resolver for this relationship's parent ID. + * + * @param string|null $key + * @return \Closure + */ + protected function resolver($key) + { + return function () use ($key) { + if (! $this->resolved) { + $instance = $this->factory instanceof Factory ? $this->factory->create() : $this->factory; + + return $this->resolved = $key ? $instance->{$key} : $instance->getKey(); + } + + return $this->resolved; + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php b/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php new file mode 100644 index 0000000000000000000000000000000000000000..b0efbd0c805bdf7d577a4aecd23798b06eb65dcf --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/CrossJoinSequence.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Illuminate\Support\Arr; + +class CrossJoinSequence extends Sequence +{ + /** + * Create a new cross join sequence instance. + * + * @param array $sequences + * @return void + */ + public function __construct(...$sequences) + { + $crossJoined = array_map( + function ($a) { + return array_merge(...$a); + }, + Arr::crossJoin(...$sequences), + ); + + parent::__construct(...$crossJoined); + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/Factory.php b/src/Illuminate/Database/Eloquent/Factories/Factory.php new file mode 100644 index 0000000000000000000000000000000000000000..56826e765a527e9db5d5e9a28aa94ca734185dae --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/Factory.php @@ -0,0 +1,836 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Closure; +use Faker\Generator; +use Illuminate\Container\Container; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\Conditionable; +use Illuminate\Support\Traits\ForwardsCalls; +use Illuminate\Support\Traits\Macroable; +use Throwable; + +abstract class Factory +{ + use Conditionable, ForwardsCalls, Macroable { + __call as macroCall; + } + + /** + * The name of the factory's corresponding model. + * + * @var string|null + */ + protected $model; + + /** + * The number of models that should be generated. + * + * @var int|null + */ + protected $count; + + /** + * The state transformations that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $states; + + /** + * The parent relationships that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $has; + + /** + * The child relationships that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $for; + + /** + * The "after making" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterMaking; + + /** + * The "after creating" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterCreating; + + /** + * The name of the database connection that will be used to create the models. + * + * @var string + */ + protected $connection; + + /** + * The current Faker instance. + * + * @var \Faker\Generator + */ + protected $faker; + + /** + * The default namespace where factories reside. + * + * @var string + */ + protected static $namespace = 'Database\\Factories\\'; + + /** + * The default model name resolver. + * + * @var callable + */ + protected static $modelNameResolver; + + /** + * The factory name resolver. + * + * @var callable + */ + protected static $factoryNameResolver; + + /** + * Create a new factory instance. + * + * @param int|null $count + * @param \Illuminate\Support\Collection|null $states + * @param \Illuminate\Support\Collection|null $has + * @param \Illuminate\Support\Collection|null $for + * @param \Illuminate\Support\Collection|null $afterMaking + * @param \Illuminate\Support\Collection|null $afterCreating + * @param string|null $connection + * @return void + */ + public function __construct($count = null, + ?Collection $states = null, + ?Collection $has = null, + ?Collection $for = null, + ?Collection $afterMaking = null, + ?Collection $afterCreating = null, + $connection = null) + { + $this->count = $count; + $this->states = $states ?: new Collection; + $this->has = $has ?: new Collection; + $this->for = $for ?: new Collection; + $this->afterMaking = $afterMaking ?: new Collection; + $this->afterCreating = $afterCreating ?: new Collection; + $this->connection = $connection; + $this->faker = $this->withFaker(); + } + + /** + * Define the model's default state. + * + * @return array + */ + abstract public function definition(); + + /** + * Get a new factory instance for the given attributes. + * + * @param callable|array $attributes + * @return static + */ + public static function new($attributes = []) + { + return (new static)->state($attributes)->configure(); + } + + /** + * Get a new factory instance for the given number of models. + * + * @param int $count + * @return static + */ + public static function times(int $count) + { + return static::new()->count($count); + } + + /** + * Configure the factory. + * + * @return $this + */ + public function configure() + { + return $this; + } + + /** + * Get the raw attributes generated by the factory. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return array + */ + public function raw($attributes = [], ?Model $parent = null) + { + if ($this->count === null) { + return $this->state($attributes)->getExpandedAttributes($parent); + } + + return array_map(function () use ($attributes, $parent) { + return $this->state($attributes)->getExpandedAttributes($parent); + }, range(1, $this->count)); + } + + /** + * Create a single model and persist it to the database. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOne($attributes = []) + { + return $this->count(null)->create($attributes); + } + + /** + * Create a single model and persist it to the database. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOneQuietly($attributes = []) + { + return $this->count(null)->createQuietly($attributes); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createMany(iterable $records) + { + return new EloquentCollection( + collect($records)->map(function ($record) { + return $this->state($record)->create(); + }) + ); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createManyQuietly(iterable $records) + { + return Model::withoutEvents(function () use ($records) { + return $this->createMany($records); + }); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model + */ + public function create($attributes = [], ?Model $parent = null) + { + if (! empty($attributes)) { + return $this->state($attributes)->create([], $parent); + } + + $results = $this->make($attributes, $parent); + + if ($results instanceof Model) { + $this->store(collect([$results])); + + $this->callAfterCreating(collect([$results]), $parent); + } else { + $this->store($results); + + $this->callAfterCreating($results, $parent); + } + + return $results; + } + + /** + * Create a collection of models and persist them to the database. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model + */ + public function createQuietly($attributes = [], ?Model $parent = null) + { + return Model::withoutEvents(function () use ($attributes, $parent) { + return $this->create($attributes, $parent); + }); + } + + /** + * Create a callback that persists a model in the database when invoked. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Closure + */ + public function lazy(array $attributes = [], ?Model $parent = null) + { + return function () use ($attributes, $parent) { + return $this->create($attributes, $parent); + }; + } + + /** + * Set the connection name on the results and store them. + * + * @param \Illuminate\Support\Collection $results + * @return void + */ + protected function store(Collection $results) + { + $results->each(function ($model) { + if (! isset($this->connection)) { + $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName()); + } + + $model->save(); + + $this->createChildren($model); + }); + } + + /** + * Create the children for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + protected function createChildren(Model $model) + { + Model::unguarded(function () use ($model) { + $this->has->each(function ($has) use ($model) { + $has->createFor($model); + }); + }); + } + + /** + * Make a single instance of the model. + * + * @param callable|array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function makeOne($attributes = []) + { + return $this->count(null)->make($attributes); + } + + /** + * Create a collection of models. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model + */ + public function make($attributes = [], ?Model $parent = null) + { + if (! empty($attributes)) { + return $this->state($attributes)->make([], $parent); + } + + if ($this->count === null) { + return tap($this->makeInstance($parent), function ($instance) { + $this->callAfterMaking(collect([$instance])); + }); + } + + if ($this->count < 1) { + return $this->newModel()->newCollection(); + } + + $instances = $this->newModel()->newCollection(array_map(function () use ($parent) { + return $this->makeInstance($parent); + }, range(1, $this->count))); + + $this->callAfterMaking($instances); + + return $instances; + } + + /** + * Make an instance of the model with the given attributes. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Model + */ + protected function makeInstance(?Model $parent) + { + return Model::unguarded(function () use ($parent) { + return tap($this->newModel($this->getExpandedAttributes($parent)), function ($instance) { + if (isset($this->connection)) { + $instance->setConnection($this->connection); + } + }); + }); + } + + /** + * Get a raw attributes array for the model. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return mixed + */ + protected function getExpandedAttributes(?Model $parent) + { + return $this->expandAttributes($this->getRawAttributes($parent)); + } + + /** + * Get the raw attributes for the model as an array. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return array + */ + protected function getRawAttributes(?Model $parent) + { + return $this->states->pipe(function ($states) { + return $this->for->isEmpty() ? $states : new Collection(array_merge([function () { + return $this->parentResolvers(); + }], $states->all())); + })->reduce(function ($carry, $state) use ($parent) { + if ($state instanceof Closure) { + $state = $state->bindTo($this); + } + + return array_merge($carry, $state($carry, $parent)); + }, $this->definition()); + } + + /** + * Create the parent relationship resolvers (as deferred Closures). + * + * @return array + */ + protected function parentResolvers() + { + $model = $this->newModel(); + + return $this->for->map(function (BelongsToRelationship $for) use ($model) { + return $for->attributesFor($model); + })->collapse()->all(); + } + + /** + * Expand all attributes to their underlying values. + * + * @param array $definition + * @return array + */ + protected function expandAttributes(array $definition) + { + return collect($definition)->map(function ($attribute, $key) use (&$definition) { + if (is_callable($attribute) && ! is_string($attribute) && ! is_array($attribute)) { + $attribute = $attribute($definition); + } + + if ($attribute instanceof self) { + $attribute = $attribute->create()->getKey(); + } elseif ($attribute instanceof Model) { + $attribute = $attribute->getKey(); + } + + $definition[$key] = $attribute; + + return $attribute; + })->all(); + } + + /** + * Add a new state transformation to the model definition. + * + * @param callable|array $state + * @return static + */ + public function state($state) + { + return $this->newInstance([ + 'states' => $this->states->concat([ + is_callable($state) ? $state : function () use ($state) { + return $state; + }, + ]), + ]); + } + + /** + * Add a new sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function sequence(...$sequence) + { + return $this->state(new Sequence(...$sequence)); + } + + /** + * Add a new cross joined sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function crossJoinSequence(...$sequence) + { + return $this->state(new CrossJoinSequence(...$sequence)); + } + + /** + * Define a child relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory $factory + * @param string|null $relationship + * @return static + */ + public function has(self $factory, $relationship = null) + { + return $this->newInstance([ + 'has' => $this->has->concat([new Relationship( + $factory, $relationship ?: $this->guessRelationship($factory->modelName()) + )]), + ]); + } + + /** + * Attempt to guess the relationship name for a "has" relationship. + * + * @param string $related + * @return string + */ + protected function guessRelationship(string $related) + { + $guess = Str::camel(Str::plural(class_basename($related))); + + return method_exists($this->modelName(), $guess) ? $guess : Str::singular($guess); + } + + /** + * Define an attached relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model $factory + * @param callable|array $pivot + * @param string|null $relationship + * @return static + */ + public function hasAttached($factory, $pivot = [], $relationship = null) + { + return $this->newInstance([ + 'has' => $this->has->concat([new BelongsToManyRelationship( + $factory, + $pivot, + $relationship ?: Str::camel(Str::plural(class_basename( + $factory instanceof Factory + ? $factory->modelName() + : Collection::wrap($factory)->first() + ))) + )]), + ]); + } + + /** + * Define a parent relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Database\Eloquent\Model $factory + * @param string|null $relationship + * @return static + */ + public function for($factory, $relationship = null) + { + return $this->newInstance(['for' => $this->for->concat([new BelongsToRelationship( + $factory, + $relationship ?: Str::camel(class_basename( + $factory instanceof Factory ? $factory->modelName() : $factory + )) + )])]); + } + + /** + * Add a new "after making" callback to the model definition. + * + * @param \Closure $callback + * @return static + */ + public function afterMaking(Closure $callback) + { + return $this->newInstance(['afterMaking' => $this->afterMaking->concat([$callback])]); + } + + /** + * Add a new "after creating" callback to the model definition. + * + * @param \Closure $callback + * @return static + */ + public function afterCreating(Closure $callback) + { + return $this->newInstance(['afterCreating' => $this->afterCreating->concat([$callback])]); + } + + /** + * Call the "after making" callbacks for the given model instances. + * + * @param \Illuminate\Support\Collection $instances + * @return void + */ + protected function callAfterMaking(Collection $instances) + { + $instances->each(function ($model) { + $this->afterMaking->each(function ($callback) use ($model) { + $callback($model); + }); + }); + } + + /** + * Call the "after creating" callbacks for the given model instances. + * + * @param \Illuminate\Support\Collection $instances + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return void + */ + protected function callAfterCreating(Collection $instances, ?Model $parent = null) + { + $instances->each(function ($model) use ($parent) { + $this->afterCreating->each(function ($callback) use ($model, $parent) { + $callback($model, $parent); + }); + }); + } + + /** + * Specify how many models should be generated. + * + * @param int|null $count + * @return static + */ + public function count(?int $count) + { + return $this->newInstance(['count' => $count]); + } + + /** + * Specify the database connection that should be used to generate models. + * + * @param string $connection + * @return static + */ + public function connection(string $connection) + { + return $this->newInstance(['connection' => $connection]); + } + + /** + * Create a new instance of the factory builder with the given mutated properties. + * + * @param array $arguments + * @return static + */ + protected function newInstance(array $arguments = []) + { + return new static(...array_values(array_merge([ + 'count' => $this->count, + 'states' => $this->states, + 'has' => $this->has, + 'for' => $this->for, + 'afterMaking' => $this->afterMaking, + 'afterCreating' => $this->afterCreating, + 'connection' => $this->connection, + ], $arguments))); + } + + /** + * Get a new model instance. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function newModel(array $attributes = []) + { + $model = $this->modelName(); + + return new $model($attributes); + } + + /** + * Get the name of the model that is generated by the factory. + * + * @return string + */ + public function modelName() + { + $resolver = static::$modelNameResolver ?: function (self $factory) { + $namespacedFactoryBasename = Str::replaceLast( + 'Factory', '', Str::replaceFirst(static::$namespace, '', get_class($factory)) + ); + + $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); + + $appNamespace = static::appNamespace(); + + return class_exists($appNamespace.'Models\\'.$namespacedFactoryBasename) + ? $appNamespace.'Models\\'.$namespacedFactoryBasename + : $appNamespace.$factoryBasename; + }; + + return $this->model ?: $resolver($this); + } + + /** + * Specify the callback that should be invoked to guess model names based on factory names. + * + * @param callable $callback + * @return void + */ + public static function guessModelNamesUsing(callable $callback) + { + static::$modelNameResolver = $callback; + } + + /** + * Specify the default namespace that contains the application's model factories. + * + * @param string $namespace + * @return void + */ + public static function useNamespace(string $namespace) + { + static::$namespace = $namespace; + } + + /** + * Get a new factory instance for the given model name. + * + * @param string $modelName + * @return static + */ + public static function factoryForModel(string $modelName) + { + $factory = static::resolveFactoryName($modelName); + + return $factory::new(); + } + + /** + * Specify the callback that should be invoked to guess factory names based on dynamic relationship names. + * + * @param callable $callback + * @return void + */ + public static function guessFactoryNamesUsing(callable $callback) + { + static::$factoryNameResolver = $callback; + } + + /** + * Get a new Faker instance. + * + * @return \Faker\Generator + */ + protected function withFaker() + { + return Container::getInstance()->make(Generator::class); + } + + /** + * Get the factory name for the given model name. + * + * @param string $modelName + * @return string + */ + public static function resolveFactoryName(string $modelName) + { + $resolver = static::$factoryNameResolver ?: function (string $modelName) { + $appNamespace = static::appNamespace(); + + $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') + ? Str::after($modelName, $appNamespace.'Models\\') + : Str::after($modelName, $appNamespace); + + return static::$namespace.$modelName.'Factory'; + }; + + return $resolver($modelName); + } + + /** + * Get the application namespace for the application. + * + * @return string + */ + protected static function appNamespace() + { + try { + return Container::getInstance() + ->make(Application::class) + ->getNamespace(); + } catch (Throwable $e) { + return 'App\\'; + } + } + + /** + * Proxy dynamic factory methods onto their proper methods. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + if (! Str::startsWith($method, ['for', 'has'])) { + static::throwBadMethodCallException($method); + } + + $relationship = Str::camel(Str::substr($method, 3)); + + $relatedModel = get_class($this->newModel()->{$relationship}()->getRelated()); + + if (method_exists($relatedModel, 'newFactory')) { + $factory = $relatedModel::newFactory() ?: static::factoryForModel($relatedModel); + } else { + $factory = static::factoryForModel($relatedModel); + } + + if (Str::startsWith($method, 'for')) { + return $this->for($factory->state($parameters[0] ?? []), $relationship); + } elseif (Str::startsWith($method, 'has')) { + return $this->has( + $factory + ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1) + ->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])), + $relationship + ); + } + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/HasFactory.php b/src/Illuminate/Database/Eloquent/Factories/HasFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..7d2be22054e6a6ea6479f9e24c45c8d156d835bd --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/HasFactory.php @@ -0,0 +1,31 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +trait HasFactory +{ + /** + * Get a new factory instance for the model. + * + * @param mixed $parameters + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public static function factory(...$parameters) + { + $factory = static::newFactory() ?: Factory::factoryForModel(get_called_class()); + + return $factory + ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : null) + ->state(is_array($parameters[0] ?? null) ? $parameters[0] : ($parameters[1] ?? [])); + } + + /** + * Create a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + // + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/Relationship.php b/src/Illuminate/Database/Eloquent/Factories/Relationship.php new file mode 100644 index 0000000000000000000000000000000000000000..788f6bc828e740aa91eacb394da061d601c2766d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/Relationship.php @@ -0,0 +1,62 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasOneOrMany; +use Illuminate\Database\Eloquent\Relations\MorphOneOrMany; + +class Relationship +{ + /** + * The related factory instance. + * + * @var \Illuminate\Database\Eloquent\Factories\Factory + */ + protected $factory; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * Create a new child relationship instance. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory $factory + * @param string $relationship + * @return void + */ + public function __construct(Factory $factory, $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; + } + + /** + * Create the child relationship for the given parent model. + * + * @param \Illuminate\Database\Eloquent\Model $parent + * @return void + */ + public function createFor(Model $parent) + { + $relationship = $parent->{$this->relationship}(); + + if ($relationship instanceof MorphOneOrMany) { + $this->factory->state([ + $relationship->getMorphType() => $relationship->getMorphClass(), + $relationship->getForeignKeyName() => $relationship->getParentKey(), + ])->create([], $parent); + } elseif ($relationship instanceof HasOneOrMany) { + $this->factory->state([ + $relationship->getForeignKeyName() => $relationship->getParentKey(), + ])->create([], $parent); + } elseif ($relationship instanceof BelongsToMany) { + $relationship->attach($this->factory->create([], $parent)); + } + } +} diff --git a/src/Illuminate/Database/Eloquent/Factories/Sequence.php b/src/Illuminate/Database/Eloquent/Factories/Sequence.php new file mode 100644 index 0000000000000000000000000000000000000000..064cc4a4e759a503c883639cbeb2c324dc54d450 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Factories/Sequence.php @@ -0,0 +1,63 @@ +<?php + +namespace Illuminate\Database\Eloquent\Factories; + +use Countable; + +class Sequence implements Countable +{ + /** + * The sequence of return values. + * + * @var array + */ + protected $sequence; + + /** + * The count of the sequence items. + * + * @var int + */ + public $count; + + /** + * The current index of the sequence iteration. + * + * @var int + */ + public $index = 0; + + /** + * Create a new sequence instance. + * + * @param array $sequence + * @return void + */ + public function __construct(...$sequence) + { + $this->sequence = $sequence; + $this->count = count($sequence); + } + + /** + * Get the current count of the sequence items. + * + * @return int + */ + public function count(): int + { + return $this->count; + } + + /** + * Get the next value in the sequence. + * + * @return mixed + */ + public function __invoke() + { + return tap(value($this->sequence[$this->index % $this->count], $this), function () { + $this->index = $this->index + 1; + }); + } +} diff --git a/src/Illuminate/Database/Eloquent/Factory.php b/src/Illuminate/Database/Eloquent/Factory.php deleted file mode 100644 index ae468238fb4d747fc16e6941847bc9b4deb2e619..0000000000000000000000000000000000000000 --- a/src/Illuminate/Database/Eloquent/Factory.php +++ /dev/null @@ -1,326 +0,0 @@ -<?php - -namespace Illuminate\Database\Eloquent; - -use ArrayAccess; -use Faker\Generator as Faker; -use Symfony\Component\Finder\Finder; - -class Factory implements ArrayAccess -{ - /** - * The model definitions in the container. - * - * @var array - */ - protected $definitions = []; - - /** - * The registered model states. - * - * @var array - */ - protected $states = []; - - /** - * The registered after making callbacks. - * - * @var array - */ - protected $afterMaking = []; - - /** - * The registered after creating callbacks. - * - * @var array - */ - protected $afterCreating = []; - - /** - * The Faker instance for the builder. - * - * @var \Faker\Generator - */ - protected $faker; - - /** - * Create a new factory instance. - * - * @param \Faker\Generator $faker - * @return void - */ - public function __construct(Faker $faker) - { - $this->faker = $faker; - } - - /** - * Create a new factory container. - * - * @param \Faker\Generator $faker - * @param string|null $pathToFactories - * @return static - */ - public static function construct(Faker $faker, $pathToFactories = null) - { - $pathToFactories = $pathToFactories ?: database_path('factories'); - - return (new static($faker))->load($pathToFactories); - } - - /** - * Define a class with a given short-name. - * - * @param string $class - * @param string $name - * @param callable $attributes - * @return $this - */ - public function defineAs($class, $name, callable $attributes) - { - return $this->define($class, $attributes, $name); - } - - /** - * Define a class with a given set of attributes. - * - * @param string $class - * @param callable $attributes - * @param string $name - * @return $this - */ - public function define($class, callable $attributes, $name = 'default') - { - $this->definitions[$class][$name] = $attributes; - - return $this; - } - - /** - * Define a state with a given set of attributes. - * - * @param string $class - * @param string $state - * @param callable|array $attributes - * @return $this - */ - public function state($class, $state, $attributes) - { - $this->states[$class][$state] = $attributes; - - return $this; - } - - /** - * Define a callback to run after making a model. - * - * @param string $class - * @param callable $callback - * @param string $name - * @return $this - */ - public function afterMaking($class, callable $callback, $name = 'default') - { - $this->afterMaking[$class][$name][] = $callback; - - return $this; - } - - /** - * Define a callback to run after making a model with given state. - * - * @param string $class - * @param string $state - * @param callable $callback - * @return $this - */ - public function afterMakingState($class, $state, callable $callback) - { - return $this->afterMaking($class, $callback, $state); - } - - /** - * Define a callback to run after creating a model. - * - * @param string $class - * @param callable $callback - * @param string $name - * @return $this - */ - public function afterCreating($class, callable $callback, $name = 'default') - { - $this->afterCreating[$class][$name][] = $callback; - - return $this; - } - - /** - * Define a callback to run after creating a model with given state. - * - * @param string $class - * @param string $state - * @param callable $callback - * @return $this - */ - public function afterCreatingState($class, $state, callable $callback) - { - return $this->afterCreating($class, $callback, $state); - } - - /** - * Create an instance of the given model and persist it to the database. - * - * @param string $class - * @param array $attributes - * @return mixed - */ - public function create($class, array $attributes = []) - { - return $this->of($class)->create($attributes); - } - - /** - * Create an instance of the given model and type and persist it to the database. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return mixed - */ - public function createAs($class, $name, array $attributes = []) - { - return $this->of($class, $name)->create($attributes); - } - - /** - * Create an instance of the given model. - * - * @param string $class - * @param array $attributes - * @return mixed - */ - public function make($class, array $attributes = []) - { - return $this->of($class)->make($attributes); - } - - /** - * Create an instance of the given model and type. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return mixed - */ - public function makeAs($class, $name, array $attributes = []) - { - return $this->of($class, $name)->make($attributes); - } - - /** - * Get the raw attribute array for a given named model. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return array - */ - public function rawOf($class, $name, array $attributes = []) - { - return $this->raw($class, $attributes, $name); - } - - /** - * Get the raw attribute array for a given model. - * - * @param string $class - * @param array $attributes - * @param string $name - * @return array - */ - public function raw($class, array $attributes = [], $name = 'default') - { - return array_merge( - call_user_func($this->definitions[$class][$name], $this->faker), $attributes - ); - } - - /** - * Create a builder for the given model. - * - * @param string $class - * @param string $name - * @return \Illuminate\Database\Eloquent\FactoryBuilder - */ - public function of($class, $name = 'default') - { - return new FactoryBuilder( - $class, $name, $this->definitions, $this->states, - $this->afterMaking, $this->afterCreating, $this->faker - ); - } - - /** - * Load factories from path. - * - * @param string $path - * @return $this - */ - public function load($path) - { - $factory = $this; - - if (is_dir($path)) { - foreach (Finder::create()->files()->name('*.php')->in($path) as $file) { - require $file->getRealPath(); - } - } - - return $factory; - } - - /** - * Determine if the given offset exists. - * - * @param string $offset - * @return bool - */ - public function offsetExists($offset) - { - return isset($this->definitions[$offset]); - } - - /** - * Get the value of the given offset. - * - * @param string $offset - * @return mixed - */ - public function offsetGet($offset) - { - return $this->make($offset); - } - - /** - * Set the given offset to the given value. - * - * @param string $offset - * @param callable $value - * @return void - */ - public function offsetSet($offset, $value) - { - $this->define($offset, $value); - } - - /** - * Unset the value at the given offset. - * - * @param string $offset - * @return void - */ - public function offsetUnset($offset) - { - unset($this->definitions[$offset]); - } -} diff --git a/src/Illuminate/Database/Eloquent/FactoryBuilder.php b/src/Illuminate/Database/Eloquent/FactoryBuilder.php deleted file mode 100644 index 97a965642352ecd1c27840e14473cb0a5ae89c02..0000000000000000000000000000000000000000 --- a/src/Illuminate/Database/Eloquent/FactoryBuilder.php +++ /dev/null @@ -1,458 +0,0 @@ -<?php - -namespace Illuminate\Database\Eloquent; - -use Faker\Generator as Faker; -use Illuminate\Support\Traits\Macroable; -use InvalidArgumentException; - -class FactoryBuilder -{ - use Macroable; - - /** - * The model definitions in the container. - * - * @var array - */ - protected $definitions; - - /** - * The model being built. - * - * @var string - */ - protected $class; - - /** - * The name of the model being built. - * - * @var string - */ - protected $name = 'default'; - - /** - * The database connection on which the model instance should be persisted. - * - * @var string - */ - protected $connection; - - /** - * The model states. - * - * @var array - */ - protected $states; - - /** - * The model after making callbacks. - * - * @var array - */ - protected $afterMaking = []; - - /** - * The model after creating callbacks. - * - * @var array - */ - protected $afterCreating = []; - - /** - * The states to apply. - * - * @var array - */ - protected $activeStates = []; - - /** - * The Faker instance for the builder. - * - * @var \Faker\Generator - */ - protected $faker; - - /** - * The number of models to build. - * - * @var int|null - */ - protected $amount = null; - - /** - * Create an new builder instance. - * - * @param string $class - * @param string $name - * @param array $definitions - * @param array $states - * @param array $afterMaking - * @param array $afterCreating - * @param \Faker\Generator $faker - * @return void - */ - public function __construct($class, $name, array $definitions, array $states, - array $afterMaking, array $afterCreating, Faker $faker) - { - $this->name = $name; - $this->class = $class; - $this->faker = $faker; - $this->states = $states; - $this->definitions = $definitions; - $this->afterMaking = $afterMaking; - $this->afterCreating = $afterCreating; - } - - /** - * Set the amount of models you wish to create / make. - * - * @param int $amount - * @return $this - */ - public function times($amount) - { - $this->amount = $amount; - - return $this; - } - - /** - * Set the state to be applied to the model. - * - * @param string $state - * @return $this - */ - public function state($state) - { - return $this->states([$state]); - } - - /** - * Set the states to be applied to the model. - * - * @param array|mixed $states - * @return $this - */ - public function states($states) - { - $this->activeStates = is_array($states) ? $states : func_get_args(); - - return $this; - } - - /** - * Set the database connection on which the model instance should be persisted. - * - * @param string $name - * @return $this - */ - public function connection($name) - { - $this->connection = $name; - - return $this; - } - - /** - * Create a model and persist it in the database if requested. - * - * @param array $attributes - * @return \Closure - */ - public function lazy(array $attributes = []) - { - return function () use ($attributes) { - return $this->create($attributes); - }; - } - - /** - * Create a collection of models and persist them to the database. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed - */ - public function create(array $attributes = []) - { - $results = $this->make($attributes); - - if ($results instanceof Model) { - $this->store(collect([$results])); - - $this->callAfterCreating(collect([$results])); - } else { - $this->store($results); - - $this->callAfterCreating($results); - } - - return $results; - } - - /** - * Create a collection of models and persist them to the database. - * - * @param iterable $records - * @return \Illuminate\Database\Eloquent\Collection|mixed - */ - public function createMany(iterable $records) - { - return (new $this->class)->newCollection(array_map(function ($attribute) { - return $this->create($attribute); - }, $records)); - } - - /** - * Set the connection name on the results and store them. - * - * @param \Illuminate\Support\Collection $results - * @return void - */ - protected function store($results) - { - $results->each(function ($model) { - if (! isset($this->connection)) { - $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName()); - } - - $model->save(); - }); - } - - /** - * Create a collection of models. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed - */ - public function make(array $attributes = []) - { - if ($this->amount === null) { - return tap($this->makeInstance($attributes), function ($instance) { - $this->callAfterMaking(collect([$instance])); - }); - } - - if ($this->amount < 1) { - return (new $this->class)->newCollection(); - } - - $instances = (new $this->class)->newCollection(array_map(function () use ($attributes) { - return $this->makeInstance($attributes); - }, range(1, $this->amount))); - - $this->callAfterMaking($instances); - - return $instances; - } - - /** - * Create an array of raw attribute arrays. - * - * @param array $attributes - * @return mixed - */ - public function raw(array $attributes = []) - { - if ($this->amount === null) { - return $this->getRawAttributes($attributes); - } - - if ($this->amount < 1) { - return []; - } - - return array_map(function () use ($attributes) { - return $this->getRawAttributes($attributes); - }, range(1, $this->amount)); - } - - /** - * Get a raw attributes array for the model. - * - * @param array $attributes - * @return mixed - * - * @throws \InvalidArgumentException - */ - protected function getRawAttributes(array $attributes = []) - { - if (! isset($this->definitions[$this->class][$this->name])) { - throw new InvalidArgumentException("Unable to locate factory with name [{$this->name}] [{$this->class}]."); - } - - $definition = call_user_func( - $this->definitions[$this->class][$this->name], - $this->faker, $attributes - ); - - return $this->expandAttributes( - array_merge($this->applyStates($definition, $attributes), $attributes) - ); - } - - /** - * Make an instance of the model with the given attributes. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model - */ - protected function makeInstance(array $attributes = []) - { - return Model::unguarded(function () use ($attributes) { - $instance = new $this->class( - $this->getRawAttributes($attributes) - ); - - if (isset($this->connection)) { - $instance->setConnection($this->connection); - } - - return $instance; - }); - } - - /** - * Apply the active states to the model definition array. - * - * @param array $definition - * @param array $attributes - * @return array - * - * @throws \InvalidArgumentException - */ - protected function applyStates(array $definition, array $attributes = []) - { - foreach ($this->activeStates as $state) { - if (! isset($this->states[$this->class][$state])) { - if ($this->stateHasAfterCallback($state)) { - continue; - } - - throw new InvalidArgumentException("Unable to locate [{$state}] state for [{$this->class}]."); - } - - $definition = array_merge( - $definition, - $this->stateAttributes($state, $attributes) - ); - } - - return $definition; - } - - /** - * Get the state attributes. - * - * @param string $state - * @param array $attributes - * @return array - */ - protected function stateAttributes($state, array $attributes) - { - $stateAttributes = $this->states[$this->class][$state]; - - if (! is_callable($stateAttributes)) { - return $stateAttributes; - } - - return $stateAttributes($this->faker, $attributes); - } - - /** - * Expand all attributes to their underlying values. - * - * @param array $attributes - * @return array - */ - protected function expandAttributes(array $attributes) - { - foreach ($attributes as &$attribute) { - if (is_callable($attribute) && ! is_string($attribute) && ! is_array($attribute)) { - $attribute = $attribute($attributes); - } - - if ($attribute instanceof static) { - $attribute = $attribute->create()->getKey(); - } - - if ($attribute instanceof Model) { - $attribute = $attribute->getKey(); - } - } - - return $attributes; - } - - /** - * Run after making callbacks on a collection of models. - * - * @param \Illuminate\Support\Collection $models - * @return void - */ - public function callAfterMaking($models) - { - $this->callAfter($this->afterMaking, $models); - } - - /** - * Run after creating callbacks on a collection of models. - * - * @param \Illuminate\Support\Collection $models - * @return void - */ - public function callAfterCreating($models) - { - $this->callAfter($this->afterCreating, $models); - } - - /** - * Call after callbacks for each model and state. - * - * @param array $afterCallbacks - * @param \Illuminate\Support\Collection $models - * @return void - */ - protected function callAfter(array $afterCallbacks, $models) - { - $states = array_merge([$this->name], $this->activeStates); - - $models->each(function ($model) use ($states, $afterCallbacks) { - foreach ($states as $state) { - $this->callAfterCallbacks($afterCallbacks, $model, $state); - } - }); - } - - /** - * Call after callbacks for each model and state. - * - * @param array $afterCallbacks - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $state - * @return void - */ - protected function callAfterCallbacks(array $afterCallbacks, $model, $state) - { - if (! isset($afterCallbacks[$this->class][$state])) { - return; - } - - foreach ($afterCallbacks[$this->class][$state] as $callback) { - $callback($model, $this->faker); - } - } - - /** - * Determine if the given state has an "after" callback. - * - * @param string $state - * @return bool - */ - protected function stateHasAfterCallback($state) - { - return isset($this->afterMaking[$this->class][$state]) || - isset($this->afterCreating[$this->class][$state]); - } -} diff --git a/src/Illuminate/Database/Eloquent/HigherOrderBuilderProxy.php b/src/Illuminate/Database/Eloquent/HigherOrderBuilderProxy.php index d238c2feac8c5cde5f5feddf8da66770e0bf6591..16b49a1b4d558c873ce74ed628ca6381cbf9edcf 100644 --- a/src/Illuminate/Database/Eloquent/HigherOrderBuilderProxy.php +++ b/src/Illuminate/Database/Eloquent/HigherOrderBuilderProxy.php @@ -26,6 +26,7 @@ class HigherOrderBuilderProxy * * @param \Illuminate\Database\Eloquent\Builder $builder * @param string $method + * @return void */ public function __construct(Builder $builder, $method) { diff --git a/src/Illuminate/Database/Eloquent/InvalidCastException.php b/src/Illuminate/Database/Eloquent/InvalidCastException.php new file mode 100644 index 0000000000000000000000000000000000000000..9d00eb3e4da380a7413d8cca9eec45e982b3b096 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/InvalidCastException.php @@ -0,0 +1,48 @@ +<?php + +namespace Illuminate\Database\Eloquent; + +use RuntimeException; + +class InvalidCastException extends RuntimeException +{ + /** + * The name of the affected Eloquent model. + * + * @var string + */ + public $model; + + /** + * The name of the column. + * + * @var string + */ + public $column; + + /** + * The name of the cast type. + * + * @var string + */ + public $castType; + + /** + * Create a new exception instance. + * + * @param object $model + * @param string $column + * @param string $castType + * @return static + */ + public function __construct($model, $column, $castType) + { + $class = get_class($model); + + parent::__construct("Call to undefined cast [{$castType}] on column [{$column}] in model [{$class}]."); + + $this->model = $class; + $this->column = $column; + $this->castType = $castType; + } +} diff --git a/src/Illuminate/Database/Eloquent/JsonEncodingException.php b/src/Illuminate/Database/Eloquent/JsonEncodingException.php index d6956da1c264a45e420d13fade44e6981f7a779c..f62abd469555399eb152872e820fa701913c18a1 100644 --- a/src/Illuminate/Database/Eloquent/JsonEncodingException.php +++ b/src/Illuminate/Database/Eloquent/JsonEncodingException.php @@ -18,6 +18,20 @@ class JsonEncodingException extends RuntimeException return new static('Error encoding model ['.get_class($model).'] with ID ['.$model->getKey().'] to JSON: '.$message); } + /** + * Create a new JSON encoding exception for the resource. + * + * @param \Illuminate\Http\Resources\Json\JsonResource $resource + * @param string $message + * @return static + */ + public static function forResource($resource, $message) + { + $model = $resource->resource; + + return new static('Error encoding resource ['.get_class($resource).'] with model ['.get_class($model).'] with ID ['.$model->getKey().'] to JSON: '.$message); + } + /** * Create a new JSON encoding exception for an attribute. * diff --git a/src/Illuminate/Database/Eloquent/MassPrunable.php b/src/Illuminate/Database/Eloquent/MassPrunable.php new file mode 100644 index 0000000000000000000000000000000000000000..254ca9bd29f02cf176df34a6935e8f8445e0d95d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/MassPrunable.php @@ -0,0 +1,48 @@ +<?php + +namespace Illuminate\Database\Eloquent; + +use Illuminate\Database\Events\ModelsPruned; +use LogicException; + +trait MassPrunable +{ + /** + * Prune all prunable models in the database. + * + * @param int $chunkSize + * @return int + */ + public function pruneAll(int $chunkSize = 1000) + { + $query = tap($this->prunable(), function ($query) use ($chunkSize) { + $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { + $query->limit($chunkSize); + }); + }); + + $total = 0; + + do { + $total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) + ? $query->forceDelete() + : $query->delete(); + + if ($count > 0) { + event(new ModelsPruned(static::class, $total)); + } + } while ($count > 0); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } +} diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 52d2dd40a998c073099d43c26dbdb2a1bf4a9e38..d1742bbebfad5ba999a85bf8af5b4ccb97c8b9c0 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -3,22 +3,27 @@ namespace Illuminate\Database\Eloquent; use ArrayAccess; -use Exception; +use Illuminate\Contracts\Broadcasting\HasBroadcastChannel; use Illuminate\Contracts\Queue\QueueableCollection; use Illuminate\Contracts\Queue\QueueableEntity; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\CanBeEscapedWhenCastToString; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Database\ConnectionResolverInterface as Resolver; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Arr; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; use JsonSerializable; +use LogicException; -abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable +abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToString, HasBroadcastChannel, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable { use Concerns\HasAttributes, Concerns\HasEvents, @@ -78,6 +83,13 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ protected $withCount = []; + /** + * Indicates whether lazy loading will be prevented on this model. + * + * @var bool + */ + public $preventsLazyLoading = false; + /** * The number of models to return for pagination. * @@ -99,6 +111,13 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ public $wasRecentlyCreated = false; + /** + * Indicates that the object's string representation should be escaped when __toString is invoked. + * + * @var bool + */ + protected $escapeWhenCastingToString = false; + /** * The connection resolver instance. * @@ -141,6 +160,27 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ protected static $ignoreOnTouch = []; + /** + * Indicates whether lazy loading should be restricted on all models. + * + * @var bool + */ + protected static $modelsShouldPreventLazyLoading = false; + + /** + * The callback that is responsible for handling lazy loading violations. + * + * @var callable|null + */ + protected static $lazyLoadingViolationCallback; + + /** + * Indicates if broadcasting is currently enabled. + * + * @var bool + */ + protected static $isBroadcasting = true; + /** * The name of the "created at" column. * @@ -184,14 +224,26 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab $this->fireModelEvent('booting', false); + static::booting(); static::boot(); + static::booted(); $this->fireModelEvent('booted', false); } } /** - * The "booting" method of the model. + * Perform any actions required before the model boots. + * + * @return void + */ + protected static function booting() + { + // + } + + /** + * Bootstrap the model and its traits. * * @return void */ @@ -244,6 +296,16 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab } } + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + // + } + /** * Clear the list of booted models so they will be re-booted. * @@ -308,6 +370,47 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return false; } + /** + * Prevent model relationships from being lazy loaded. + * + * @param bool $value + * @return void + */ + public static function preventLazyLoading($value = true) + { + static::$modelsShouldPreventLazyLoading = $value; + } + + /** + * Register a callback that is responsible for handling lazy loading violations. + * + * @param callable|null $callback + * @return void + */ + public static function handleLazyLoadingViolationUsing(?callable $callback) + { + static::$lazyLoadingViolationCallback = $callback; + } + + /** + * Execute a callback without broadcasting any model events for all model types. + * + * @param callable $callback + * @return mixed + */ + public static function withoutBroadcasting(callable $callback) + { + $isBroadcasting = static::$isBroadcasting; + + static::$isBroadcasting = false; + + try { + return $callback(); + } finally { + static::$isBroadcasting = $isBroadcasting; + } + } + /** * Fill the model with an array of attributes. * @@ -321,8 +424,6 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab $totallyGuarded = $this->totallyGuarded(); foreach ($this->fillableFromArray($attributes) as $key => $value) { - $key = $this->removeTableFromKey($key); - // The developers may choose to place some attributes in the "fillable" array // which means only those attributes may be set through mass assignment to // the model, and all others will just get ignored for security reasons. @@ -368,14 +469,16 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab } /** - * Remove the table name from a given key. + * Qualify the given columns with the model's table. * - * @param string $key - * @return string + * @param array $columns + * @return array */ - protected function removeTableFromKey($key) + public function qualifyColumns($columns) { - return $key; + return collect($columns)->map(function ($column) { + return $this->qualifyColumn($column); + })->all(); } /** @@ -400,6 +503,8 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab $model->setTable($this->getTable()); + $model->mergeCasts($this->casts); + return $model; } @@ -494,6 +599,26 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return $this; } + /** + * Eager load relationships on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorph($relation, $relations) + { + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->load($relations[$className] ?? []); + + return $this; + } + /** * Eager load relations on the model if they are not already eager loaded. * @@ -509,6 +634,21 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return $this; } + /** + * Eager load relation's column aggregations on the model. + * + * @param array|string $relations + * @param string $column + * @param string $function + * @return $this + */ + public function loadAggregate($relations, $column, $function = null) + { + $this->newCollection([$this])->loadAggregate($relations, $column, $function); + + return $this; + } + /** * Eager load relation counts on the model. * @@ -519,11 +659,154 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab { $relations = is_string($relations) ? func_get_args() : $relations; - $this->newCollection([$this])->loadCount($relations); + return $this->loadAggregate($relations, '*', 'count'); + } + + /** + * Eager load relation max column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMax($relations, $column) + { + return $this->loadAggregate($relations, $column, 'max'); + } + + /** + * Eager load relation min column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMin($relations, $column) + { + return $this->loadAggregate($relations, $column, 'min'); + } + + /** + * Eager load relation's column summations on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadSum($relations, $column) + { + return $this->loadAggregate($relations, $column, 'sum'); + } + + /** + * Eager load relation average column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadAvg($relations, $column) + { + return $this->loadAggregate($relations, $column, 'avg'); + } + + /** + * Eager load related model existence values on the model. + * + * @param array|string $relations + * @return $this + */ + public function loadExists($relations) + { + return $this->loadAggregate($relations, '*', 'exists'); + } + + /** + * Eager load relationship column aggregation on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @param string $function + * @return $this + */ + public function loadMorphAggregate($relation, $relations, $column, $function = null) + { + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->loadAggregate($relations[$className] ?? [], $column, $function); return $this; } + /** + * Eager load relationship counts on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + return $this->loadMorphAggregate($relation, $relations, '*', 'count'); + } + + /** + * Eager load relationship max column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphMax($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'max'); + } + + /** + * Eager load relationship min column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphMin($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'min'); + } + + /** + * Eager load relationship column summations on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphSum($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'sum'); + } + + /** + * Eager load relationship average column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphAvg($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'avg'); + } + /** * Increment a column's value by a given amount. * @@ -567,45 +850,73 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return $query->{$method}($column, $amount, $extra); } - $this->incrementOrDecrementAttributeValue($column, $amount, $extra, $method); + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($method, $column, $amount) + : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + + $this->forceFill($extra); + + if ($this->fireModelEvent('updating') === false) { + return false; + } + + return tap($this->setKeysForSaveQuery($query)->{$method}($column, $amount, $extra), function () use ($column) { + $this->syncChanges(); + + $this->fireModelEvent('updated', false); - return $query->where( - $this->getKeyName(), $this->getKey() - )->{$method}($column, $amount, $extra); + $this->syncOriginalAttribute($column); + }); } /** - * Increment the underlying attribute value and sync with original. + * Update the model in the database. * - * @param string $column - * @param float|int $amount - * @param array $extra - * @param string $method - * @return void + * @param array $attributes + * @param array $options + * @return bool */ - protected function incrementOrDecrementAttributeValue($column, $amount, $extra, $method) + public function update(array $attributes = [], array $options = []) { - $this->{$column} = $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + if (! $this->exists) { + return false; + } - $this->forceFill($extra); + return $this->fill($attributes)->save($options); + } + + /** + * Update the model in the database within a transaction. + * + * @param array $attributes + * @param array $options + * @return bool + * + * @throws \Throwable + */ + public function updateOrFail(array $attributes = [], array $options = []) + { + if (! $this->exists) { + return false; + } - $this->syncOriginalAttribute($column); + return $this->fill($attributes)->saveOrFail($options); } /** - * Update the model in the database. + * Update the model in the database without raising any events. * * @param array $attributes * @param array $options * @return bool */ - public function update(array $attributes = [], array $options = []) + public function updateQuietly(array $attributes = [], array $options = []) { if (! $this->exists) { return false; } - return $this->fill($attributes)->save($options); + return $this->fill($attributes)->saveQuietly($options); } /** @@ -636,6 +947,19 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return true; } + /** + * Save the model to the database without raising any events. + * + * @param array $options + * @return bool + */ + public function saveQuietly(array $options = []) + { + return static::withoutEvents(function () use ($options) { + return $this->save($options); + }); + } + /** * Save the model to the database. * @@ -644,6 +968,8 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ public function save(array $options = []) { + $this->mergeAttributesFromCachedCasts(); + $query = $this->newModelQuery(); // If the "saving" event returns false we'll bail out of the save and return @@ -684,7 +1010,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab } /** - * Save the model to the database using transaction. + * Save the model to the database within a transaction. * * @param array $options * @return bool @@ -753,13 +1079,36 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return true; } + /** + * Set the keys for a select query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery($query) + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()); + + return $query; + } + + /** + * Get the primary key value for a select query. + * + * @return mixed + */ + protected function getKeyForSelectQuery() + { + return $this->original[$this->getKeyName()] ?? $this->getKey(); + } + /** * Set the keys for a save update query. * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSaveQuery($query) { $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()); @@ -773,8 +1122,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ protected function getKeyForSaveQuery() { - return $this->original[$this->getKeyName()] - ?? $this->getKey(); + return $this->original[$this->getKeyName()] ?? $this->getKey(); } /** @@ -799,7 +1147,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab // If the model has an incrementing key, we can use the "insertGetId" method on // the query builder, which will give us back the final inserted ID for this // table from the database. Not all tables have to be incrementing though. - $attributes = $this->getAttributes(); + $attributes = $this->getAttributesForInsert(); if ($this->getIncrementing()) { $this->insertAndSetId($query, $attributes); @@ -845,15 +1193,14 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab /** * Destroy the models for the given IDs. * - * @param \Illuminate\Support\Collection|array|int $ids + * @param \Illuminate\Support\Collection|array|int|string $ids * @return int */ public static function destroy($ids) { - // We'll initialize a count here so we will return the total number of deletes - // for the operation. The developers can then check this number as a boolean - // type value or get this total count of records deleted for logging, etc. - $count = 0; + if ($ids instanceof EloquentCollection) { + $ids = $ids->modelKeys(); + } if ($ids instanceof BaseCollection) { $ids = $ids->all(); @@ -861,11 +1208,17 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab $ids = is_array($ids) ? $ids : func_get_args(); + if (count($ids) === 0) { + return 0; + } + // We will actually pull the models from the database table and call delete on // each of them individually so that their events get fired properly with a // correct set of attributes in case the developers wants to check these. $key = ($instance = new static)->getKeyName(); + $count = 0; + foreach ($instance->whereIn($key, $ids)->get() as $model) { if ($model->delete()) { $count++; @@ -880,12 +1233,14 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * * @return bool|null * - * @throws \Exception + * @throws \LogicException */ public function delete() { + $this->mergeAttributesFromCachedCasts(); + if (is_null($this->getKeyName())) { - throw new Exception('No primary key defined on model.'); + throw new LogicException('No primary key defined on model.'); } // If the model doesn't exist, there is nothing to delete so we'll just return @@ -914,10 +1269,28 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return true; } + /** + * Delete the model from the database within a transaction. + * + * @return bool|null + * + * @throws \Throwable + */ + public function deleteOrFail() + { + if (! $this->exists) { + return false; + } + + return $this->getConnection()->transaction(function () { + return $this->delete(); + }); + } + /** * Force a hard delete on a soft deleted model. * - * This method protects developers from running forceDelete when trait is missing. + * This method protects developers from running forceDelete when the trait is missing. * * @return bool|null */ @@ -1079,6 +1452,29 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab : Pivot::fromAttributes($parent, $attributes, $table, $exists); } + /** + * Determine if the model has a given scope. + * + * @param string $scope + * @return bool + */ + public function hasNamedScope($scope) + { + return method_exists($this, 'scope'.ucfirst($scope)); + } + + /** + * Apply the given named scope if possible. + * + * @param string $scope + * @param array $parameters + * @return mixed + */ + public function callNamedScope($scope, array $parameters = []) + { + return $this->{'scope'.ucfirst($scope)}(...$parameters); + } + /** * Convert the model instance to an array. * @@ -1113,6 +1509,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); @@ -1130,9 +1527,8 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return; } - return static::newQueryWithoutScopes() + return $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) ->with(is_string($with) ? func_get_args() : $with) - ->where($this->getKeyName(), $this->getKey()) ->first(); } @@ -1148,7 +1544,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab } $this->setRawAttributes( - static::newQueryWithoutScopes()->findOrFail($this->getKey())->attributes + $this->setKeysForSelectQuery($this->newQueryWithoutScopes())->firstOrFail()->attributes ); $this->load(collect($this->relations)->reject(function ($relation) { @@ -1176,7 +1572,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab ]; $attributes = Arr::except( - $this->attributes, $except ? array_unique(array_merge($except, $defaults)) : $defaults + $this->getAttributes(), $except ? array_unique(array_merge($except, $defaults)) : $defaults ); return tap(new static, function ($instance) use ($attributes) { @@ -1476,11 +1872,87 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * Retrieve the model for a bound value. * * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveRouteBinding($value, $field = null) + { + return $this->resolveRouteBindingQuery($this, $value, $field)->first(); + } + + /** + * Retrieve the model for a bound value. + * + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveSoftDeletableRouteBinding($value, $field = null) + { + return $this->resolveRouteBindingQuery($this, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveChildRouteBinding($childType, $value, $field) + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->first(); + } + + /** + * Retrieve the child model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field * @return \Illuminate\Database\Eloquent\Model|null */ - public function resolveRouteBinding($value) + public function resolveSoftDeletableChildRouteBinding($childType, $value, $field) + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model query for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + protected function resolveChildRouteBindingQuery($childType, $value, $field) + { + $relationship = $this->{Str::plural(Str::camel($childType))}(); + + $field = $field ?: $relationship->getRelated()->getRouteKeyName(); + + if ($relationship instanceof HasManyThrough || + $relationship instanceof BelongsToMany) { + $field = $relationship->getRelated()->getTable().'.'.$field; + } + + return $relationship instanceof Model + ? $relationship->resolveRouteBindingQuery($relationship, $value, $field) + : $relationship->getRelated()->resolveRouteBindingQuery($relationship, $value, $field); + } + + /** + * Retrieve the model for a bound value. + * + * @param \Illuminate\Database\Eloquent\Model|Illuminate\Database\Eloquent\Relations\Relation $query + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function resolveRouteBindingQuery($query, $value, $field = null) { - return $this->where($this->getRouteKeyName(), $value)->first(); + return $query->where($field ?? $this->getRouteKeyName(), $value); } /** @@ -1516,6 +1988,36 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return $this; } + /** + * Determine if lazy loading is disabled. + * + * @return bool + */ + public static function preventsLazyLoading() + { + return static::$modelsShouldPreventLazyLoading; + } + + /** + * Get the broadcast channel route definition that is associated with the given entity. + * + * @return string + */ + public function broadcastChannelRoute() + { + return str_replace('\\', '.', get_class($this)).'.{'.Str::camel(class_basename($this)).'}'; + } + + /** + * Get the broadcast channel name that is associated with the given entity. + * + * @return string + */ + public function broadcastChannel() + { + return str_replace('\\', '.', get_class($this)).'.'.$this->getKey(); + } + /** * Dynamically retrieve attributes on the model. * @@ -1545,6 +2047,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * @param mixed $offset * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return ! is_null($this->getAttribute($offset)); @@ -1556,6 +2059,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * @param mixed $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->getAttribute($offset); @@ -1568,6 +2072,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->setAttribute($offset, $value); @@ -1579,6 +2084,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab * @param mixed $offset * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->attributes[$offset], $this->relations[$offset]); @@ -1619,11 +2125,15 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab return $this->$method(...$parameters); } + if ($resolver = (static::$relationResolvers[get_class($this)][$method] ?? null)) { + return $resolver($this); + } + return $this->forwardCallTo($this->newQuery(), $method, $parameters); } /** - * Handle dynamic static method calls into the method. + * Handle dynamic static method calls into the model. * * @param string $method * @param array $parameters @@ -1641,7 +2151,37 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ public function __toString() { - return $this->toJson(); + return $this->escapeWhenCastingToString + ? e($this->toJson()) + : $this->toJson(); + } + + /** + * Indicate that the object's string representation should be escaped when __toString is invoked. + * + * @param bool $escape + * @return $this + */ + public function escapeWhenCastingToString($escape = true) + { + $this->escapeWhenCastingToString = $escape; + + return $this; + } + + /** + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep() + { + $this->mergeAttributesFromCachedCasts(); + + $this->classCastCache = []; + $this->attributeCastCache = []; + + return array_keys(get_object_vars($this)); } /** @@ -1652,5 +2192,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab public function __wakeup() { $this->bootIfNotBooted(); + + $this->initializeTraits(); } } diff --git a/src/Illuminate/Database/Eloquent/ModelNotFoundException.php b/src/Illuminate/Database/Eloquent/ModelNotFoundException.php index 2795b934bb7438aa6ba10aba648fb25045778c6f..c35598bdbf4660a1a3d5b7e031b23d205520f1f1 100755 --- a/src/Illuminate/Database/Eloquent/ModelNotFoundException.php +++ b/src/Illuminate/Database/Eloquent/ModelNotFoundException.php @@ -2,10 +2,10 @@ namespace Illuminate\Database\Eloquent; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Support\Arr; -use RuntimeException; -class ModelNotFoundException extends RuntimeException +class ModelNotFoundException extends RecordsNotFoundException { /** * Name of the affected Eloquent model. diff --git a/src/Illuminate/Database/Eloquent/Prunable.php b/src/Illuminate/Database/Eloquent/Prunable.php new file mode 100644 index 0000000000000000000000000000000000000000..b4ce1b03403a2eb61dab0807f797c794f5caf665 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -0,0 +1,67 @@ +<?php + +namespace Illuminate\Database\Eloquent; + +use Illuminate\Database\Events\ModelsPruned; +use LogicException; + +trait Prunable +{ + /** + * Prune all prunable models in the database. + * + * @param int $chunkSize + * @return int + */ + public function pruneAll(int $chunkSize = 1000) + { + $total = 0; + + $this->prunable() + ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) { + $query->withTrashed(); + })->chunkById($chunkSize, function ($models) use (&$total) { + $models->each->prune(); + + $total += $models->count(); + + event(new ModelsPruned(static::class, $total)); + }); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } + + /** + * Prune the model in the database. + * + * @return bool|null + */ + public function prune() + { + $this->pruning(); + + return in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) + ? $this->forceDelete() + : $this->delete(); + } + + /** + * Prepare the model for pruning. + * + * @return void + */ + protected function pruning() + { + // + } +} diff --git a/src/Illuminate/Database/Eloquent/RelationNotFoundException.php b/src/Illuminate/Database/Eloquent/RelationNotFoundException.php index 5acc0b309562ea6f1399f618866b7083dd283812..73257bb101e0b45b925b4533ebf4a726c2adcccc 100755 --- a/src/Illuminate/Database/Eloquent/RelationNotFoundException.php +++ b/src/Illuminate/Database/Eloquent/RelationNotFoundException.php @@ -25,13 +25,18 @@ class RelationNotFoundException extends RuntimeException * * @param object $model * @param string $relation + * @param string|null $type * @return static */ - public static function make($model, $relation) + public static function make($model, $relation, $type = null) { $class = get_class($model); - $instance = new static("Call to undefined relationship [{$relation}] on model [{$class}]."); + $instance = new static( + is_null($type) + ? "Call to undefined relationship [{$relation}] on model [{$class}]." + : "Call to undefined relationship [{$relation}] on model [{$class}] of type [{$type}].", + ); $instance->model = $class; $instance->relation = $relation; diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php b/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php index 28c5fd25f8d87fc667280b907f7bad8055ee5df8..c17b733a100542122896221a81825a67e1179bb0 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php @@ -5,14 +5,20 @@ namespace Illuminate\Database\Eloquent\Relations; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; class BelongsTo extends Relation { - use SupportsDefaultModels; + use ComparesRelatedModels, + InteractsWithDictionary, + SupportsDefaultModels; /** * The child model instance of the relation. + * + * @var \Illuminate\Database\Eloquent\Model */ protected $child; @@ -37,13 +43,6 @@ class BelongsTo extends Relation */ protected $relationName; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new belongs to relationship instance. * @@ -52,7 +51,6 @@ class BelongsTo extends Relation * @param string $foreignKey * @param string $ownerKey * @param string $relationName - * * @return void */ public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relationName) @@ -178,15 +176,19 @@ class BelongsTo extends Relation $dictionary = []; foreach ($results as $result) { - $dictionary[$result->getAttribute($owner)] = $result; + $attribute = $this->getDictionaryKey($result->getAttribute($owner)); + + $dictionary[$attribute] = $result; } // Once we have the dictionary constructed, we can loop through all the parents // and match back onto their children using these keys of the dictionary and // the primary key of the children to map them onto the correct instances. foreach ($models as $model) { - if (isset($dictionary[$model->{$foreign}])) { - $model->setRelation($relation, $dictionary[$model->{$foreign}]); + $attribute = $this->getDictionaryKey($model->{$foreign}); + + if (isset($dictionary[$attribute])) { + $model->setRelation($relation, $dictionary[$attribute]); } } @@ -196,7 +198,7 @@ class BelongsTo extends Relation /** * Associate the model instance to the given parent. * - * @param \Illuminate\Database\Eloquent\Model|int|string $model + * @param \Illuminate\Database\Eloquent\Model|int|string|null $model * @return \Illuminate\Database\Eloquent\Model */ public function associate($model) @@ -207,7 +209,7 @@ class BelongsTo extends Relation if ($model instanceof Model) { $this->child->setRelation($this->relationName, $model); - } elseif ($this->child->isDirty($this->foreignKey)) { + } else { $this->child->unsetRelation($this->relationName); } @@ -226,6 +228,16 @@ class BelongsTo extends Relation return $this->child->setRelation($this->relationName, null); } + /** + * Alias of "dissociate" method. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function disassociate() + { + return $this->dissociate(); + } + /** * Add the constraints for a relationship query. * @@ -266,16 +278,6 @@ class BelongsTo extends Relation ); } - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - /** * Determine if the related model has an auto-incrementing ID. * @@ -284,7 +286,7 @@ class BelongsTo extends Relation protected function relationHasIncrementingId() { return $this->related->getIncrementing() && - $this->related->getKeyType() === 'int'; + in_array($this->related->getKeyType(), ['int', 'integer']); } /** @@ -328,6 +330,16 @@ class BelongsTo extends Relation return $this->child->qualifyColumn($this->foreignKey); } + /** + * Get the key value of the child's foreign key. + * + * @return mixed + */ + public function getParentKey() + { + return $this->child->{$this->foreignKey}; + } + /** * Get the associated key of the relationship. * @@ -348,6 +360,17 @@ class BelongsTo extends Relation return $this->related->qualifyColumn($this->ownerKey); } + /** + * Get the value of the model's associated key. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->{$this->ownerKey}; + } + /** * Get the name of the relationship. * diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 59490329f34124e1b98d92cdb05638f183ba6fba..4cadd7407238e3056a6c9442cac997a398ba5d0c 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -2,16 +2,21 @@ namespace Illuminate\Database\Eloquent\Relations; +use Closure; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; use Illuminate\Support\Str; use InvalidArgumentException; class BelongsToMany extends Relation { - use Concerns\InteractsWithPivotTable; + use InteractsWithDictionary, InteractsWithPivotTable; /** * The intermediate table for the relation. @@ -76,6 +81,13 @@ class BelongsToMany extends Relation */ protected $pivotWhereIns = []; + /** + * Any pivot table restrictions for whereNull clauses. + * + * @var array + */ + protected $pivotWhereNulls = []; + /** * The default values for the pivot columns. * @@ -118,13 +130,6 @@ class BelongsToMany extends Relation */ protected $accessor = 'pivot'; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new belongs to many relationship instance. * @@ -135,7 +140,7 @@ class BelongsToMany extends Relation * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName + * @param string|null $relationName * @return void */ public function __construct(Builder $query, Model $parent, $table, $foreignPivotKey, @@ -169,7 +174,7 @@ class BelongsToMany extends Relation return $table; } - if ($model instanceof Pivot) { + if (in_array(AsPivot::class, class_uses_recursive($model))) { $this->using($table); } @@ -203,11 +208,12 @@ class BelongsToMany extends Relation // We need to join to the intermediate table on the related model's primary // key column with the intermediate table's foreign key for the related // model instance. Then we can set the "where" for the parent models. - $baseTable = $this->related->getTable(); - - $key = $baseTable.'.'.$this->relatedKey; - - $query->join($this->table, $key, '=', $this->getQualifiedRelatedPivotKeyName()); + $query->join( + $this->table, + $this->getQualifiedRelatedKeyName(), + '=', + $this->getQualifiedRelatedPivotKeyName() + ); return $this; } @@ -272,9 +278,11 @@ class BelongsToMany extends Relation // Once we have an array dictionary of child objects we can easily match the // children back to their parent using the dictionary and the keys on the - // the parent models. Then we will return the hydrated models back out. + // parent models. Then we should return these hydrated models back out. foreach ($models as $model) { - if (isset($dictionary[$key = $model->{$this->parentKey}])) { + $key = $this->getDictionaryKey($model->{$this->parentKey}); + + if (isset($dictionary[$key])) { $model->setRelation( $relation, $this->related->newCollection($dictionary[$key]) ); @@ -298,7 +306,9 @@ class BelongsToMany extends Relation $dictionary = []; foreach ($results as $result) { - $dictionary[$result->{$this->accessor}->{$this->foreignPivotKey}][] = $result; + $value = $this->getDictionaryKey($result->{$this->accessor}->{$this->foreignPivotKey}); + + $dictionary[$value][] = $result; } return $dictionary; @@ -344,7 +354,7 @@ class BelongsToMany extends Relation * Set a where clause for a pivot table column. * * @param string $column - * @param string|null $operator + * @param mixed $operator * @param mixed $value * @param string $boolean * @return $this @@ -353,7 +363,58 @@ class BelongsToMany extends Relation { $this->pivotWheres[] = func_get_args(); - return $this->where($this->table.'.'.$column, $operator, $value, $boolean); + return $this->where($this->qualifyPivotColumn($column), $operator, $value, $boolean); + } + + /** + * Set a "where between" clause for a pivot table column. + * + * @param string $column + * @param array $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function wherePivotBetween($column, array $values, $boolean = 'and', $not = false) + { + return $this->whereBetween($this->qualifyPivotColumn($column), $values, $boolean, $not); + } + + /** + * Set a "or where between" clause for a pivot table column. + * + * @param string $column + * @param array $values + * @return $this + */ + public function orWherePivotBetween($column, array $values) + { + return $this->wherePivotBetween($column, $values, 'or'); + } + + /** + * Set a "where pivot not between" clause for a pivot table column. + * + * @param string $column + * @param array $values + * @param string $boolean + * @return $this + */ + public function wherePivotNotBetween($column, array $values, $boolean = 'and') + { + return $this->wherePivotBetween($column, $values, $boolean, true); + } + + /** + * Set a "or where not between" clause for a pivot table column. + * + * @param string $column + * @param array $values + * @return $this + */ + public function orWherePivotNotBetween($column, array $values) + { + return $this->wherePivotBetween($column, $values, 'or', true); } /** @@ -369,14 +430,14 @@ class BelongsToMany extends Relation { $this->pivotWhereIns[] = func_get_args(); - return $this->whereIn($this->table.'.'.$column, $values, $boolean, $not); + return $this->whereIn($this->qualifyPivotColumn($column), $values, $boolean, $not); } /** * Set an "or where" clause for a pivot table column. * * @param string $column - * @param string|null $operator + * @param mixed $operator * @param mixed $value * @return $this */ @@ -453,7 +514,69 @@ class BelongsToMany extends Relation } /** - * Find a related model by its primary key or return new instance of the related model. + * Set a "where null" clause for a pivot table column. + * + * @param string $column + * @param string $boolean + * @param bool $not + * @return $this + */ + public function wherePivotNull($column, $boolean = 'and', $not = false) + { + $this->pivotWhereNulls[] = func_get_args(); + + return $this->whereNull($this->qualifyPivotColumn($column), $boolean, $not); + } + + /** + * Set a "where not null" clause for a pivot table column. + * + * @param string $column + * @param string $boolean + * @return $this + */ + public function wherePivotNotNull($column, $boolean = 'and') + { + return $this->wherePivotNull($column, $boolean, true); + } + + /** + * Set a "or where null" clause for a pivot table column. + * + * @param string $column + * @param bool $not + * @return $this + */ + public function orWherePivotNull($column, $not = false) + { + return $this->wherePivotNull($column, 'or', $not); + } + + /** + * Set a "or where not null" clause for a pivot table column. + * + * @param string $column + * @return $this + */ + public function orWherePivotNotNull($column) + { + return $this->orWherePivotNull($column, true); + } + + /** + * Add an "order by" clause for a pivot table column. + * + * @param string $column + * @param string $direction + * @return $this + */ + public function orderByPivot($column, $direction = 'asc') + { + return $this->orderBy($this->qualifyPivotColumn($column), $direction); + } + + /** + * Find a related model by its primary key or return a new instance of the related model. * * @param mixed $id * @param array $columns @@ -531,7 +654,11 @@ class BelongsToMany extends Relation */ public function find($id, $columns = ['*']) { - return is_array($id) ? $this->findMany($id, $columns) : $this->where( + if (! $id instanceof Model && (is_array($id) || $id instanceof Arrayable)) { + return $this->findMany($id, $columns); + } + + return $this->where( $this->getRelated()->getQualifiedKeyName(), '=', $this->parseId($id) )->first($columns); } @@ -539,13 +666,19 @@ class BelongsToMany extends Relation /** * Find multiple related models by their primary keys. * - * @param mixed $ids + * @param \Illuminate\Contracts\Support\Arrayable|array $ids * @param array $columns * @return \Illuminate\Database\Eloquent\Collection */ public function findMany($ids, $columns = ['*']) { - return empty($ids) ? $this->getRelated()->newCollection() : $this->whereIn( + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->getRelated()->newCollection(); + } + + return $this->whereIn( $this->getRelated()->getQualifiedKeyName(), $this->parseIds($ids) )->get($columns); } @@ -563,6 +696,8 @@ class BelongsToMany extends Relation { $result = $this->find($id, $columns); + $id = $id instanceof Arrayable ? $id->toArray() : $id; + if (is_array($id)) { if (count($result) === count(array_unique($id))) { return $result; @@ -618,6 +753,28 @@ class BelongsToMany extends Relation throw (new ModelNotFoundException)->setModel(get_class($this->related)); } + /** + * Execute the query and get the first result or call a callback. + * + * @param \Closure|array $columns + * @param \Closure|null $callback + * @return \Illuminate\Database\Eloquent\Model|static|mixed + */ + public function firstOr($columns = ['*'], Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + /** * Get the results of the relationship. * @@ -639,7 +796,7 @@ class BelongsToMany extends Relation public function get($columns = ['*']) { // First we'll add the proper select columns onto the query so it is run with - // the proper columns. Then, we will get the results and hydrate out pivot + // the proper columns. Then, we will get the results and hydrate our pivot // models with the result of those columns as a separate model relation. $builder = $this->query->applyScopes(); @@ -688,7 +845,7 @@ class BelongsToMany extends Relation $defaults = [$this->foreignPivotKey, $this->relatedPivotKey]; return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { - return $this->table.'.'.$column.' as pivot_'.$column; + return $this->qualifyPivotColumn($column).' as pivot_'.$column; })->unique()->all(); } @@ -728,6 +885,24 @@ class BelongsToMany extends Relation }); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) { + $this->hydratePivotRelation($paginator->items()); + }); + } + /** * Chunk the results of the query. * @@ -737,12 +912,10 @@ class BelongsToMany extends Relation */ public function chunk($count, callable $callback) { - $this->query->addSelect($this->shouldSelect()); - - return $this->query->chunk($count, function ($results) use ($callback) { + return $this->prepareQueryBuilder()->chunk($count, function ($results, $page) use ($callback) { $this->hydratePivotRelation($results->all()); - return $callback($results); + return $callback($results, $page); }); } @@ -757,7 +930,7 @@ class BelongsToMany extends Relation */ public function chunkById($count, callable $callback, $column = null, $alias = null) { - $this->query->addSelect($this->shouldSelect()); + $this->prepareQueryBuilder(); $column = $column ?? $this->getRelated()->qualifyColumn( $this->getRelatedKeyName() @@ -790,6 +963,44 @@ class BelongsToMany extends Relation }); } + /** + * Query lazily, by chunks of the given size. + * + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + */ + public function lazy($chunkSize = 1000) + { + return $this->prepareQueryBuilder()->lazy($chunkSize)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @param int $chunkSize + * @param string|null $column + * @param string|null $alias + * @return \Illuminate\Support\LazyCollection + */ + public function lazyById($chunkSize = 1000, $column = null, $alias = null) + { + $column = $column ?? $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias = $alias ?? $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->lazyById($chunkSize, $column, $alias)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + /** * Get a lazy collection for the given query. * @@ -797,15 +1008,23 @@ class BelongsToMany extends Relation */ public function cursor() { - $this->query->addSelect($this->shouldSelect()); - - return $this->query->cursor()->map(function ($model) { + return $this->prepareQueryBuilder()->cursor()->map(function ($model) { $this->hydratePivotRelation([$model]); return $model; }); } + /** + * Prepare the query builder for query execution. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function prepareQueryBuilder() + { + return $this->query->addSelect($this->shouldSelect()); + } + /** * Hydrate the pivot table relationship on the models. * @@ -1044,16 +1263,6 @@ class BelongsToMany extends Relation return $this->getQualifiedForeignPivotKeyName(); } - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - /** * Specify that the pivot table has creation and update timestamps. * @@ -1108,7 +1317,7 @@ class BelongsToMany extends Relation */ public function getQualifiedForeignPivotKeyName() { - return $this->table.'.'.$this->foreignPivotKey; + return $this->qualifyPivotColumn($this->foreignPivotKey); } /** @@ -1128,7 +1337,7 @@ class BelongsToMany extends Relation */ public function getQualifiedRelatedPivotKeyName() { - return $this->table.'.'.$this->relatedPivotKey; + return $this->qualifyPivotColumn($this->relatedPivotKey); } /** @@ -1161,6 +1370,16 @@ class BelongsToMany extends Relation return $this->relatedKey; } + /** + * Get the fully qualified related key name for the relation. + * + * @return string + */ + public function getQualifiedRelatedKeyName() + { + return $this->related->qualifyColumn($this->relatedKey); + } + /** * Get the intermediate table for the relationship. * @@ -1200,4 +1419,17 @@ class BelongsToMany extends Relation { return $this->pivotColumns; } + + /** + * Qualify the given column name by the pivot table. + * + * @param string $column + * @return string + */ + public function qualifyPivotColumn($column) + { + return Str::contains($column, '.') + ? $column + : $this->table.'.'.$column; + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/AsPivot.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/AsPivot.php index de9f0725338162fff12c98f831fa398a699f80ac..af9defb7463dd86aedcd2ff41b7e9e7b174de8a1 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/AsPivot.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/AsPivot.php @@ -83,15 +83,15 @@ trait AsPivot } /** - * Set the keys for a save update query. + * Set the keys for a select query. * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSelectQuery($query) { if (isset($this->attributes[$this->getKeyName()])) { - return parent::setKeysForSaveQuery($query); + return parent::setKeysForSelectQuery($query); } $query->where($this->foreignKey, $this->getOriginal( @@ -103,6 +103,17 @@ trait AsPivot )); } + /** + * Set the keys for a save update query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery($query) + { + return $this->setKeysForSelectQuery($query); + } + /** * Delete the pivot model record from the database. * @@ -121,6 +132,8 @@ trait AsPivot $this->touchOwners(); return tap($this->getDeleteQuery()->delete(), function () { + $this->exists = false; + $this->fireModelEvent('deleted', false); }); } diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/CanBeOneOfMany.php new file mode 100644 index 0000000000000000000000000000000000000000..6f6b1b7f1fa3a82f5ac82115a4d7aff9ad4309ca --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -0,0 +1,313 @@ +<?php + +namespace Illuminate\Database\Eloquent\Relations\Concerns; + +use Closure; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use InvalidArgumentException; + +trait CanBeOneOfMany +{ + /** + * Determines whether the relationship is one-of-many. + * + * @var bool + */ + protected $isOneOfMany = false; + + /** + * The name of the relationship. + * + * @var string + */ + protected $relationName; + + /** + * The one of many inner join subselect query builder instance. + * + * @var \Illuminate\Database\Eloquent\Builder|null + */ + protected $oneOfManySubQuery; + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + abstract public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null); + + /** + * Get the columns the determine the relationship groups. + * + * @return array|string + */ + abstract public function getOneOfManySubQuerySelectColumns(); + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\JoinClause $join + * @return void + */ + abstract public function addOneOfManyJoinSubQueryConstraints(JoinClause $join); + + /** + * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|Closure|null $aggregate + * @param string|null $relation + * @return $this + * + * @throws \InvalidArgumentException + */ + public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null) + { + $this->isOneOfMany = true; + + $this->relationName = $relation ?: $this->getDefaultOneOfManyJoinAlias( + $this->guessRelationship() + ); + + $keyName = $this->query->getModel()->getKeyName(); + + $columns = is_string($columns = $column) ? [ + $column => $aggregate, + $keyName => $aggregate, + ] : $column; + + if (! array_key_exists($keyName, $columns)) { + $columns[$keyName] = 'MAX'; + } + + if ($aggregate instanceof Closure) { + $closure = $aggregate; + } + + foreach ($columns as $column => $aggregate) { + if (! in_array(strtolower($aggregate), ['min', 'max'])) { + throw new InvalidArgumentException("Invalid aggregate [{$aggregate}] used within ofMany relation. Available aggregates: MIN, MAX"); + } + + $subQuery = $this->newOneOfManySubQuery( + $this->getOneOfManySubQuerySelectColumns(), + $column, $aggregate + ); + + if (isset($previous)) { + $this->addOneOfManyJoinSubQuery($subQuery, $previous['subQuery'], $previous['column']); + } + + if (isset($closure)) { + $closure($subQuery); + } + + if (! isset($previous)) { + $this->oneOfManySubQuery = $subQuery; + } + + if (array_key_last($columns) == $column) { + $this->addOneOfManyJoinSubQuery($this->query, $subQuery, $column); + } + + $previous = [ + 'subQuery' => $subQuery, + 'column' => $column, + ]; + } + + $this->addConstraints(); + + $columns = $this->query->getQuery()->columns; + + if (is_null($columns) || $columns === ['*']) { + $this->select([$this->qualifyColumn('*')]); + } + + return $this; + } + + /** + * Indicate that the relation is the latest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|Closure|null $aggregate + * @param string|null $relation + * @return $this + */ + public function latestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return [$column => 'MAX']; + })->all(), 'MAX', $relation); + } + + /** + * Indicate that the relation is the oldest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|Closure|null $aggregate + * @param string|null $relation + * @return $this + */ + public function oldestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return [$column => 'MIN']; + })->all(), 'MIN', $relation); + } + + /** + * Get the default alias for the one of many inner join clause. + * + * @param string $relation + * @return string + */ + protected function getDefaultOneOfManyJoinAlias($relation) + { + return $relation == $this->query->getModel()->getTable() + ? $relation.'_of_many' + : $relation; + } + + /** + * Get a new query for the related model, grouping the query by the given column, often the foreign key of the relationship. + * + * @param string|array $groupBy + * @param string|null $column + * @param string|null $aggregate + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function newOneOfManySubQuery($groupBy, $column = null, $aggregate = null) + { + $subQuery = $this->query->getModel() + ->newQuery() + ->withoutGlobalScopes($this->removedScopes()); + + foreach (Arr::wrap($groupBy) as $group) { + $subQuery->groupBy($this->qualifyRelatedColumn($group)); + } + + if (! is_null($column)) { + $subQuery->selectRaw($aggregate.'('.$subQuery->getQuery()->grammar->wrap($subQuery->qualifyColumn($column)).') as '.$subQuery->getQuery()->grammar->wrap($column.'_aggregate')); + } + + $this->addOneOfManySubQueryConstraints($subQuery, $groupBy, $column, $aggregate); + + return $subQuery; + } + + /** + * Add the join subquery to the given query on the given column and the relationship's foreign key. + * + * @param \Illuminate\Database\Eloquent\Builder $parent + * @param \Illuminate\Database\Eloquent\Builder $subQuery + * @param string $on + * @return void + */ + protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, $on) + { + $parent->beforeQuery(function ($parent) use ($subQuery, $on) { + $subQuery->applyBeforeQueryCallbacks(); + + $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { + $join->on($this->qualifySubSelectColumn($on.'_aggregate'), '=', $this->qualifyRelatedColumn($on)); + + $this->addOneOfManyJoinSubQueryConstraints($join, $on); + }); + }); + } + + /** + * Merge the relationship query joins to the given query builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function mergeOneOfManyJoinsTo(Builder $query) + { + $query->getQuery()->beforeQueryCallbacks = $this->query->getQuery()->beforeQueryCallbacks; + + $query->applyBeforeQueryCallbacks(); + } + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function getRelationQuery() + { + return $this->isOneOfMany() + ? $this->oneOfManySubQuery + : $this->query; + } + + /** + * Get the one of many inner join subselect builder instance. + * + * @return \Illuminate\Database\Eloquent\Builder|void + */ + public function getOneOfManySubQuery() + { + return $this->oneOfManySubQuery; + } + + /** + * Get the qualified column name for the one-of-many relationship using the subselect join query's alias. + * + * @param string $column + * @return string + */ + public function qualifySubSelectColumn($column) + { + return $this->getRelationName().'.'.last(explode('.', $column)); + } + + /** + * Qualify related column using the related table name if it is not already qualified. + * + * @param string $column + * @return string + */ + protected function qualifyRelatedColumn($column) + { + return Str::contains($column, '.') ? $column : $this->query->getModel()->getTable().'.'.$column; + } + + /** + * Guess the "hasOne" relationship's name via backtrace. + * + * @return string + */ + protected function guessRelationship() + { + return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + } + + /** + * Determine whether the relationship is a one-of-many relationship. + * + * @return bool + */ + public function isOneOfMany() + { + return $this->isOneOfMany; + } + + /** + * Get the name of the relationship. + * + * @return string + */ + public function getRelationName() + { + return $this->relationName; + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php new file mode 100644 index 0000000000000000000000000000000000000000..ca06698875e85f0e5fbb8b811ed598aa280252e0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -0,0 +1,77 @@ +<?php + +namespace Illuminate\Database\Eloquent\Relations\Concerns; + +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Model; + +trait ComparesRelatedModels +{ + /** + * Determine if the model is the related instance of the relationship. + * + * @param \Illuminate\Database\Eloquent\Model|null $model + * @return bool + */ + public function is($model) + { + $match = ! is_null($model) && + $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) && + $this->related->getTable() === $model->getTable() && + $this->related->getConnectionName() === $model->getConnectionName(); + + if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { + return $this->query + ->whereKey($model->getKey()) + ->exists(); + } + + return $match; + } + + /** + * Determine if the model is not the related instance of the relationship. + * + * @param \Illuminate\Database\Eloquent\Model|null $model + * @return bool + */ + public function isNot($model) + { + return ! $this->is($model); + } + + /** + * Get the value of the parent model's key. + * + * @return mixed + */ + abstract public function getParentKey(); + + /** + * Get the value of the model's related key. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + abstract protected function getRelatedKeyFrom(Model $model); + + /** + * Compare the parent key with the related key. + * + * @param mixed $parentKey + * @param mixed $relatedKey + * @return bool + */ + protected function compareKeys($parentKey, $relatedKey) + { + if (empty($parentKey) || empty($relatedKey)) { + return false; + } + + if (is_int($parentKey) || is_int($relatedKey)) { + return (int) $parentKey === (int) $relatedKey; + } + + return $parentKey === $relatedKey; + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php new file mode 100644 index 0000000000000000000000000000000000000000..ba4ae9aeb655d870eae8ec5575f3cc5c77dc42a1 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithDictionary.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Database\Eloquent\Relations\Concerns; + +use BackedEnum; +use Doctrine\Instantiator\Exception\InvalidArgumentException; + +trait InteractsWithDictionary +{ + /** + * Get a dictionary key attribute - casting it to a string if necessary. + * + * @param mixed $attribute + * @return mixed + * + * @throws \Doctrine\Instantiator\Exception\InvalidArgumentException + */ + protected function getDictionaryKey($attribute) + { + if (is_object($attribute)) { + if (method_exists($attribute, '__toString')) { + return $attribute->__toString(); + } + + if (function_exists('enum_exists') && + $attribute instanceof BackedEnum) { + return $attribute->value; + } + + throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); + } + + return $attribute; + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index c6812b75a15060da7e91f9955d30c767f1ac3f45..7a1cbfaedb5d74d220b7411fb8d99e321625efee 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -116,13 +116,29 @@ trait InteractsWithPivotTable // have done any attaching or detaching, and if we have we will touch these // relationships if they are configured to touch on any database updates. if (count($changes['attached']) || - count($changes['updated'])) { + count($changes['updated']) || + count($changes['detached'])) { $this->touchIfTouching(); } return $changes; } + /** + * Sync the intermediate tables with a list of IDs or collection of models with the given pivot values. + * + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids + * @param array $values + * @param bool $detaching + * @return array + */ + public function syncWithPivotValues($ids, array $values, bool $detaching = true) + { + return $this->sync(collect($this->parseIds($ids))->mapWithKeys(function ($id) use ($values) { + return [$id => $values]; + }), $detaching); + } + /** * Format the sync / toggle record list so that it is keyed by ID. * @@ -184,7 +200,10 @@ trait InteractsWithPivotTable */ public function updateExistingPivot($id, array $attributes, $touch = true) { - if ($this->using && empty($this->pivotWheres) && empty($this->pivotWhereIns)) { + if ($this->using && + empty($this->pivotWheres) && + empty($this->pivotWhereIns) && + empty($this->pivotWhereNulls)) { return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); } @@ -220,14 +239,9 @@ trait InteractsWithPivotTable $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; - $pivot = $this->newPivot([ - $this->foreignPivotKey => $this->parent->{$this->parentKey}, - $this->relatedPivotKey => $this->parseId($id), - ], true); - - $pivot->timestamps = $updated && in_array($this->updatedAt(), $this->pivotColumns); - - $pivot->fill($attributes)->save(); + if ($updated) { + $pivot->save(); + } if ($touch) { $this->touchIfTouching(); @@ -414,7 +428,11 @@ trait InteractsWithPivotTable */ public function detach($ids = null, $touch = true) { - if ($this->using && ! empty($ids) && empty($this->pivotWheres) && empty($this->pivotWhereIns)) { + if ($this->using && + ! empty($ids) && + empty($this->pivotWheres) && + empty($this->pivotWhereIns) && + empty($this->pivotWhereNulls)) { $results = $this->detachUsingCustomClass($ids); } else { $query = $this->newPivotQuery(); @@ -429,7 +447,7 @@ trait InteractsWithPivotTable return 0; } - $query->whereIn($this->relatedPivotKey, (array) $ids); + $query->whereIn($this->getQualifiedRelatedPivotKeyName(), (array) $ids); } // Once we have all of the conditions set on the statement, we are ready @@ -473,9 +491,11 @@ trait InteractsWithPivotTable protected function getCurrentlyAttachedPivots() { return $this->newPivotQuery()->get()->map(function ($record) { - $class = $this->using ? $this->using : Pivot::class; + $class = $this->using ?: Pivot::class; - return (new $class)->setRawAttributes((array) $record, true); + $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true); + + return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); }); } @@ -544,7 +564,11 @@ trait InteractsWithPivotTable $query->whereIn(...$arguments); } - return $query->where($this->foreignPivotKey, $this->parent->{$this->parentKey}); + foreach ($this->pivotWhereNulls as $arguments) { + $query->whereNull(...$arguments); + } + + return $query->where($this->getQualifiedForeignPivotKeyName(), $this->parent->{$this->parentKey}); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index 3fe3b8d5de9832711c785726b8d6f524a5317536..207481679829d45f12cba17358617a155c2bb826 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -2,14 +2,18 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\SoftDeletes; class HasManyThrough extends Relation { + use InteractsWithDictionary; + /** * The "through" parent model instance. * @@ -52,13 +56,6 @@ class HasManyThrough extends Relation */ protected $secondLocalKey; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new has many through relationship instance. * @@ -114,7 +111,9 @@ class HasManyThrough extends Relation $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); if ($this->throughParentSoftDeletes()) { - $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + $query->withGlobalScope('SoftDeletableHasManyThrough', function ($query) { + $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + }); } } @@ -138,6 +137,18 @@ class HasManyThrough extends Relation return in_array(SoftDeletes::class, class_uses_recursive($this->throughParent)); } + /** + * Indicate that trashed "through" parents should be included in the query. + * + * @return $this + */ + public function withTrashedParents() + { + $this->query->withoutGlobalScope('SoftDeletableHasManyThrough'); + + return $this; + } + /** * Set the constraints for an eager load of the relation. * @@ -185,7 +196,7 @@ class HasManyThrough extends Relation // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { $model->setRelation( $relation, $this->related->newCollection($dictionary[$key]) ); @@ -299,7 +310,7 @@ class HasManyThrough extends Relation */ public function find($id, $columns = ['*']) { - if (is_array($id)) { + if (is_array($id) || $id instanceof Arrayable) { return $this->findMany($id, $columns); } @@ -311,12 +322,14 @@ class HasManyThrough extends Relation /** * Find multiple related models by their primary keys. * - * @param mixed $ids + * @param \Illuminate\Contracts\Support\Arrayable|array $ids * @param array $columns * @return \Illuminate\Database\Eloquent\Collection */ public function findMany($ids, $columns = ['*']) { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + if (empty($ids)) { return $this->getRelated()->newCollection(); } @@ -339,6 +352,8 @@ class HasManyThrough extends Relation { $result = $this->find($id, $columns); + $id = $id instanceof Arrayable ? $id->toArray() : $id; + if (is_array($id)) { if (count($result) === count(array_unique($id))) { return $result; @@ -416,6 +431,22 @@ class HasManyThrough extends Relation return $this->query->simplePaginate($perPage, $columns, $pageName, $page); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor); + } + /** * Set the select clause for the relation query. * @@ -489,6 +520,34 @@ class HasManyThrough extends Relation }); } + /** + * Query lazily, by chunks of the given size. + * + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + */ + public function lazy($chunkSize = 1000) + { + return $this->prepareQueryBuilder()->lazy($chunkSize); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @param int $chunkSize + * @param string|null $column + * @param string|null $alias + * @return \Illuminate\Support\LazyCollection + */ + public function lazyById($chunkSize = 1000, $column = null, $alias = null) + { + $column = $column ?? $this->getRelated()->getQualifiedKeyName(); + + $alias = $alias ?? $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->lazyById($chunkSize, $column, $alias); + } + /** * Prepare the query builder for query execution. * @@ -577,16 +636,6 @@ class HasManyThrough extends Relation ); } - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - /** * Get the qualified foreign key on the related model. * diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOne.php b/src/Illuminate/Database/Eloquent/Relations/HasOne.php index 1d9e008fd23125f3b160c99a4513f7fffe76db55..15c735c328fc72e8ecc9db2f4497cab763229a13 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOne.php @@ -2,13 +2,18 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class HasOne extends HasOneOrMany +class HasOne extends HasOneOrMany implements SupportsPartialRelations { - use SupportsDefaultModels; + use ComparesRelatedModels, CanBeOneOfMany, SupportsDefaultModels; /** * Get the results of the relationship. @@ -53,6 +58,59 @@ class HasOne extends HasOneOrMany return $this->matchOne($models, $results, $relation); } + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like "whereColumn". + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param array|mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) + { + $query->addSelect($this->foreignKey); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return $this->foreignKey; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + /** * Make a new related instance for the given model. * @@ -65,4 +123,15 @@ class HasOne extends HasOneOrMany $this->getForeignKeyName(), $parent->{$this->localKey} ); } + + /** + * Get the value of the model's foreign key. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->getAttribute($this->getForeignKeyName()); + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index bc547702aa7adeb3ed3a07446d604a6ee4bad4a7..ff738b9ad28e41f1e7dc357bae654a9236fb364b 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -5,9 +5,12 @@ namespace Illuminate\Database\Eloquent\Relations; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; abstract class HasOneOrMany extends Relation { + use InteractsWithDictionary; + /** * The foreign key of the parent model. * @@ -22,13 +25,6 @@ abstract class HasOneOrMany extends Relation */ protected $localKey; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new has one or many relationship instance. * @@ -59,6 +55,23 @@ abstract class HasOneOrMany extends Relation }); } + /** + * Create and return an un-saved instance of the related models. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function makeMany($records) + { + $instances = $this->related->newCollection(); + + foreach ($records as $record) { + $instances->push($this->make($record)); + } + + return $instances; + } + /** * Set the base constraints on the relation query. * @@ -67,9 +80,11 @@ abstract class HasOneOrMany extends Relation public function addConstraints() { if (static::$constraints) { - $this->query->where($this->foreignKey, '=', $this->getParentKey()); + $query = $this->getRelationQuery(); + + $query->where($this->foreignKey, '=', $this->getParentKey()); - $this->query->whereNotNull($this->foreignKey); + $query->whereNotNull($this->foreignKey); } } @@ -83,7 +98,7 @@ abstract class HasOneOrMany extends Relation { $whereIn = $this->whereInMethod($this->parent, $this->localKey); - $this->query->{$whereIn}( + $this->getRelationQuery()->{$whereIn}( $this->foreignKey, $this->getKeys($models, $this->localKey) ); } @@ -131,7 +146,7 @@ abstract class HasOneOrMany extends Relation // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { $model->setRelation( $relation, $this->getRelationValue($dictionary, $key, $type) ); @@ -167,12 +182,12 @@ abstract class HasOneOrMany extends Relation $foreign = $this->getForeignKeyName(); return $results->mapToDictionary(function ($result) use ($foreign) { - return [$result->{$foreign} => $result]; + return [$this->getDictionaryKey($result->{$foreign}) => $result]; })->all(); } /** - * Find a model by its primary key or return new instance of the related model. + * Find a model by its primary key or return a new instance of the related model. * * @param mixed $id * @param array $columns @@ -196,10 +211,10 @@ abstract class HasOneOrMany extends Relation * @param array $values * @return \Illuminate\Database\Eloquent\Model */ - public function firstOrNew(array $attributes, array $values = []) + public function firstOrNew(array $attributes = [], array $values = []) { if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance($attributes + $values); + $instance = $this->related->newInstance(array_merge($attributes, $values)); $this->setForeignAttributesForCreate($instance); } @@ -214,10 +229,10 @@ abstract class HasOneOrMany extends Relation * @param array $values * @return \Illuminate\Database\Eloquent\Model */ - public function firstOrCreate(array $attributes, array $values = []) + public function firstOrCreate(array $attributes = [], array $values = []) { if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->create($attributes + $values); + $instance = $this->create(array_merge($attributes, $values)); } return $instance; @@ -282,6 +297,19 @@ abstract class HasOneOrMany extends Relation }); } + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function forceCreate(array $attributes = []) + { + $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + + return $this->related->forceCreate($attributes); + } + /** * Create a Collection of new instances of the related model. * @@ -346,16 +374,6 @@ abstract class HasOneOrMany extends Relation ); } - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - /** * Get the key for comparing against the parent key in "has" query. * diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasOneThrough.php index a48c3186214afb46624fe7926d12afaafd816291..ed9c7baa4dc37bfd4c965e4d8627d83d074815ee 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneThrough.php @@ -4,11 +4,12 @@ namespace Illuminate\Database\Eloquent\Relations; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; class HasOneThrough extends HasManyThrough { - use SupportsDefaultModels; + use InteractsWithDictionary, SupportsDefaultModels; /** * Get the results of the relationship. @@ -52,7 +53,7 @@ class HasOneThrough extends HasManyThrough // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { $value = $dictionary[$key]; $model->setRelation( $relation, reset($value) diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php index 12b06502632971931687558cfc0e0616640a008e..282ba2e86053a1caebd6764ba3deb4bc4ed84cb8 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php @@ -46,4 +46,17 @@ class MorphMany extends MorphOneOrMany { return $this->matchMany($models, $results, $relation); } + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @param array $attributes + * @return \Illuminate\Database\Eloquent\Model + */ + public function forceCreate(array $attributes = []) + { + $attributes[$this->getMorphType()] = $this->morphClass; + + return parent::forceCreate($attributes); + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php index 5f8da14f1f46092396e6c93c299a126fa4c94e20..ff526842ec0390d3f652e192938d2c12acff82dc 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php @@ -2,13 +2,18 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class MorphOne extends MorphOneOrMany +class MorphOne extends MorphOneOrMany implements SupportsPartialRelations { - use SupportsDefaultModels; + use CanBeOneOfMany, ComparesRelatedModels, SupportsDefaultModels; /** * Get the results of the relationship. @@ -53,6 +58,59 @@ class MorphOne extends MorphOneOrMany return $this->matchOne($models, $results, $relation); } + /** + * Get the relationship query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param array|mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) + { + $query->addSelect($this->foreignKey, $this->morphType); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return [$this->foreignKey, $this->morphType]; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join + ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->morphType)) + ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + /** * Make a new related instance for the given model. * @@ -65,4 +123,15 @@ class MorphOne extends MorphOneOrMany ->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) ->setAttribute($this->getMorphType(), $this->morphClass); } + + /** + * Get the value of the model's foreign key. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->getAttribute($this->getForeignKeyName()); + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 887ebe2476b14005a67b3244a0125d8f09dded71..6e2297fcc88ee71bf9edd3996f44be895630574b 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -48,9 +48,9 @@ abstract class MorphOneOrMany extends HasOneOrMany public function addConstraints() { if (static::$constraints) { - parent::addConstraints(); + $this->getRelationQuery()->where($this->morphType, $this->morphClass); - $this->query->where($this->morphType, $this->morphClass); + parent::addConstraints(); } } @@ -64,7 +64,7 @@ abstract class MorphOneOrMany extends HasOneOrMany { parent::addEagerConstraints($models); - $this->query->where($this->morphType, $this->morphClass); + $this->getRelationQuery()->where($this->morphType, $this->morphClass); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php b/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php index 68489265f838331c1bae624d61a64c42e519e504..7fbe484aac99df99e2a2137014e647f6e07b9109 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php @@ -2,7 +2,6 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; class MorphPivot extends Pivot @@ -31,13 +30,26 @@ class MorphPivot extends Pivot * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSaveQuery($query) { $query->where($this->morphType, $this->morphClass); return parent::setKeysForSaveQuery($query); } + /** + * Set the keys for a select query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery($query) + { + $query->where($this->morphType, $this->morphClass); + + return parent::setKeysForSelectQuery($query); + } + /** * Delete the pivot model record from the database. * @@ -62,6 +74,16 @@ class MorphPivot extends Pivot }); } + /** + * Get the morph type for the pivot. + * + * @return string + */ + public function getMorphType() + { + return $this->morphType; + } + /** * Set the morph type for the pivot. * diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphTo.php b/src/Illuminate/Database/Eloquent/Relations/MorphTo.php index f0911c9dc31edd779cbc9268454b3b0ef96928bd..262741f30cfbcd2543e762dceb8f3d6480b210eb 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphTo.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphTo.php @@ -6,9 +6,12 @@ use BadMethodCallException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; class MorphTo extends BelongsTo { + use InteractsWithDictionary; + /** * The type of the polymorphic relation. * @@ -44,6 +47,20 @@ class MorphTo extends BelongsTo */ protected $morphableEagerLoads = []; + /** + * A map of relationship counts to load for each individual morph type. + * + * @var array + */ + protected $morphableEagerLoadCounts = []; + + /** + * A map of constraints to apply for each individual morph type. + * + * @var array + */ + protected $morphableConstraints = []; + /** * Create a new morph to relationship instance. * @@ -83,7 +100,10 @@ class MorphTo extends BelongsTo { foreach ($models as $model) { if ($model->{$this->morphType}) { - $this->dictionary[$model->{$this->morphType}][$model->{$this->foreignKey}][] = $model; + $morphTypeKey = $this->getDictionaryKey($model->{$this->morphType}); + $foreignKeyKey = $this->getDictionaryKey($model->{$this->foreignKey}); + + $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; } } } @@ -121,12 +141,19 @@ class MorphTo extends BelongsTo ->with(array_merge( $this->getQuery()->getEagerLoads(), (array) ($this->morphableEagerLoads[get_class($instance)] ?? []) - )); + )) + ->withCount( + (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) + ); + + if ($callback = ($this->morphableConstraints[get_class($instance)] ?? null)) { + $callback($query); + } $whereIn = $this->whereInMethod($instance, $ownerKey); return $query->{$whereIn}( - $instance->getTable().'.'.$ownerKey, $this->gatherKeysByType($type) + $instance->getTable().'.'.$ownerKey, $this->gatherKeysByType($type, $instance->getKeyType()) )->get(); } @@ -134,11 +161,16 @@ class MorphTo extends BelongsTo * Gather all of the foreign keys for a given type. * * @param string $type + * @param string $keyType * @return array */ - protected function gatherKeysByType($type) + protected function gatherKeysByType($type, $keyType) { - return array_keys($this->dictionary[$type]); + return $keyType !== 'string' + ? array_keys($this->dictionary[$type]) + : array_map(function ($modelId) { + return (string) $modelId; + }, array_filter(array_keys($this->dictionary[$type]))); } /** @@ -181,7 +213,7 @@ class MorphTo extends BelongsTo protected function matchToMorphParents($type, Collection $results) { foreach ($results as $result) { - $ownerKey = ! is_null($this->ownerKey) ? $result->{$this->ownerKey} : $result->getKey(); + $ownerKey = ! is_null($this->ownerKey) ? $this->getDictionaryKey($result->{$this->ownerKey}) : $result->getKey(); if (isset($this->dictionary[$type][$ownerKey])) { foreach ($this->dictionary[$type][$ownerKey] as $model) { @@ -199,8 +231,14 @@ class MorphTo extends BelongsTo */ public function associate($model) { + if ($model instanceof Model) { + $foreignKey = $this->ownerKey && $model->{$this->ownerKey} + ? $this->ownerKey + : $model->getKeyName(); + } + $this->parent->setAttribute( - $this->foreignKey, $model instanceof Model ? $model->getKey() : null + $this->foreignKey, $model instanceof Model ? $model->{$foreignKey} : null ); $this->parent->setAttribute( @@ -282,6 +320,36 @@ class MorphTo extends BelongsTo return $this; } + /** + * Specify which relationship counts to load for a given morph type. + * + * @param array $withCount + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function morphWithCount(array $withCount) + { + $this->morphableEagerLoadCounts = array_merge( + $this->morphableEagerLoadCounts, $withCount + ); + + return $this; + } + + /** + * Specify constraints on the query for a given morph type. + * + * @param array $callbacks + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function constrain(array $callbacks) + { + $this->morphableConstraints = array_merge( + $this->morphableConstraints, $callbacks + ); + + return $this; + } + /** * Replay stored macro calls on the actual related instance. * diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php index 0adf385e13d67648ff1d4c0808c97554d0b45c25..c2d5745582240129c9be59eb38571afc66dabf5a 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php @@ -68,7 +68,7 @@ class MorphToMany extends BelongsToMany { parent::addWhereConstraints(); - $this->query->where($this->table.'.'.$this->morphType, $this->morphClass); + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); return $this; } @@ -83,7 +83,7 @@ class MorphToMany extends BelongsToMany { parent::addEagerConstraints($models); - $this->query->where($this->table.'.'.$this->morphType, $this->morphClass); + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); } /** @@ -111,7 +111,7 @@ class MorphToMany extends BelongsToMany public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( - $this->table.'.'.$this->morphType, $this->morphClass + $this->qualifyPivotColumn($this->morphType), $this->morphClass ); } @@ -173,7 +173,7 @@ class MorphToMany extends BelongsToMany $defaults = [$this->foreignPivotKey, $this->relatedPivotKey, $this->morphType]; return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { - return $this->table.'.'.$column.' as pivot_'.$column; + return $this->qualifyPivotColumn($column).' as pivot_'.$column; })->unique()->all(); } diff --git a/src/Illuminate/Database/Eloquent/Relations/Relation.php b/src/Illuminate/Database/Eloquent/Relations/Relation.php index 43ff446b54047076573e8772d485572c81059efa..aa8ce5a07da0abd2774be306acc42c67c62be323 100755 --- a/src/Illuminate/Database/Eloquent/Relations/Relation.php +++ b/src/Illuminate/Database/Eloquent/Relations/Relation.php @@ -6,6 +6,8 @@ use Closure; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\MultipleRecordsFoundException; use Illuminate\Database\Query\Expression; use Illuminate\Support\Arr; use Illuminate\Support\Traits\ForwardsCalls; @@ -49,12 +51,26 @@ abstract class Relation protected static $constraints = true; /** - * An array to map class names to their morph names in database. + * An array to map class names to their morph names in the database. * * @var array */ public static $morphMap = []; + /** + * Prevents morph relationships without a morph map. + * + * @var bool + */ + protected static $requireMorphMap = false; + + /** + * The count of self joins. + * + * @var int + */ + protected static $selfJoinCount = 0; + /** * Create a new relation instance. * @@ -144,6 +160,30 @@ abstract class Relation return $this->get(); } + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Model + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function sole($columns = ['*']) + { + $result = $this->take(2)->get($columns); + + if ($result->isEmpty()) { + throw (new ModelNotFoundException)->setModel(get_class($this->related)); + } + + if ($result->count() > 1) { + throw new MultipleRecordsFoundException; + } + + return $result->first(); + } + /** * Execute the query as a "select" statement. * @@ -213,11 +253,22 @@ abstract class Relation ); } + /** + * Get a relationship join table hash. + * + * @param bool $incrementJoinCount + * @return string + */ + public function getRelationCountHash($incrementJoinCount = true) + { + return 'laravel_reserved_'.($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount); + } + /** * Get all of the primary keys for an array of models. * * @param array $models - * @param string $key + * @param string|null $key * @return array */ protected function getKeys(array $models, $key = null) @@ -227,6 +278,16 @@ abstract class Relation })->values()->unique(null, true)->sort()->all(); } + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function getRelationQuery() + { + return $this->query; + } + /** * Get the underlying query for the relation. * @@ -322,6 +383,41 @@ abstract class Relation : 'whereIn'; } + /** + * Prevent polymorphic relationships from being used without model mappings. + * + * @param bool $requireMorphMap + * @return void + */ + public static function requireMorphMap($requireMorphMap = true) + { + static::$requireMorphMap = $requireMorphMap; + } + + /** + * Determine if polymorphic relationships require explicit model mapping. + * + * @return bool + */ + public static function requiresMorphMap() + { + return static::$requireMorphMap; + } + + /** + * Define the morph map for polymorphic relations and require all morphed models to be explicitly mapped. + * + * @param array $map + * @param bool $merge + * @return array + */ + public static function enforceMorphMap(array $map, $merge = true) + { + static::requireMorphMap(); + + return static::morphMap($map, $merge); + } + /** * Set or get the morph map for polymorphic relations. * @@ -382,13 +478,7 @@ abstract class Relation return $this->macroCall($method, $parameters); } - $result = $this->forwardCallTo($this->query, $method, $parameters); - - if ($result === $this->query) { - return $this; - } - - return $result; + return $this->forwardDecoratedCallTo($this->query, $method, $parameters); } /** diff --git a/src/Illuminate/Database/Eloquent/SoftDeletes.php b/src/Illuminate/Database/Eloquent/SoftDeletes.php index 6784f70309719f8de9e05e2d5bc4ed7c15425181..aa6c817849729098b0dd40f518e8c3712a24d40e 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletes.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletes.php @@ -3,7 +3,7 @@ namespace Illuminate\Database\Eloquent; /** - * @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withTrashed() + * @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withTrashed(bool $withTrashed = true) * @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyTrashed() * @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutTrashed() */ @@ -33,7 +33,9 @@ trait SoftDeletes */ public function initializeSoftDeletes() { - $this->dates[] = $this->getDeletedAtColumn(); + if (! isset($this->casts[$this->getDeletedAtColumn()])) { + $this->casts[$this->getDeletedAtColumn()] = 'datetime'; + } } /** @@ -62,9 +64,9 @@ trait SoftDeletes protected function performDeleteOnModel() { if ($this->forceDeleting) { - $this->exists = false; - - return $this->setKeysForSaveQuery($this->newModelQuery())->forceDelete(); + return tap($this->setKeysForSaveQuery($this->newModelQuery())->forceDelete(), function () { + $this->exists = false; + }); } return $this->runSoftDelete(); @@ -94,6 +96,8 @@ trait SoftDeletes $query->update($columns); $this->syncOriginalAttributes(array_keys($columns)); + + $this->fireModelEvent('trashed', false); } /** @@ -135,7 +139,18 @@ trait SoftDeletes } /** - * Register a restoring model event with the dispatcher. + * Register a "softDeleted" model event callback with the dispatcher. + * + * @param \Closure|string $callback + * @return void + */ + public static function softDeleted($callback) + { + static::registerModelEvent('trashed', $callback); + } + + /** + * Register a "restoring" model event callback with the dispatcher. * * @param \Closure|string $callback * @return void @@ -146,7 +161,7 @@ trait SoftDeletes } /** - * Register a restored model event with the dispatcher. + * Register a "restored" model event callback with the dispatcher. * * @param \Closure|string $callback * @return void @@ -156,6 +171,17 @@ trait SoftDeletes static::registerModelEvent('restored', $callback); } + /** + * Register a "forceDeleted" model event callback with the dispatcher. + * + * @param \Closure|string $callback + * @return void + */ + public static function forceDeleted($callback) + { + static::registerModelEvent('forceDeleted', $callback); + } + /** * Determine if the model is currently force deleting. * diff --git a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php index 0d5169662490280c01ec54f00e88043829fcc244..7528964c132ac8fbdf500ff689ea9f605c558bdf 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php @@ -7,7 +7,7 @@ class SoftDeletingScope implements Scope /** * All of the extensions to be added to the builder. * - * @var array + * @var string[] */ protected $extensions = ['Restore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; diff --git a/src/Illuminate/Database/Events/DatabaseRefreshed.php b/src/Illuminate/Database/Events/DatabaseRefreshed.php new file mode 100644 index 0000000000000000000000000000000000000000..5b1fb45856b306e058010ec5c5ed11e2295bee14 --- /dev/null +++ b/src/Illuminate/Database/Events/DatabaseRefreshed.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Database\Events; + +use Illuminate\Contracts\Database\Events\MigrationEvent as MigrationEventContract; + +class DatabaseRefreshed implements MigrationEventContract +{ + // +} diff --git a/src/Illuminate/Database/Events/MigrationEvent.php b/src/Illuminate/Database/Events/MigrationEvent.php index 53b91a56b372d2b22008cd10959afa19f0a89780..157303d2e2b5699c619f9721867ffebbbe6df273 100644 --- a/src/Illuminate/Database/Events/MigrationEvent.php +++ b/src/Illuminate/Database/Events/MigrationEvent.php @@ -8,7 +8,7 @@ use Illuminate\Database\Migrations\Migration; abstract class MigrationEvent implements MigrationEventContract { /** - * An migration instance. + * A migration instance. * * @var \Illuminate\Database\Migrations\Migration */ diff --git a/src/Illuminate/Database/Events/MigrationsEnded.php b/src/Illuminate/Database/Events/MigrationsEnded.php index 387f6a9d69a28dc2fb331fb66333325a928f0241..f668281944675ee86167c7aec89f02df608d6b46 100644 --- a/src/Illuminate/Database/Events/MigrationsEnded.php +++ b/src/Illuminate/Database/Events/MigrationsEnded.php @@ -2,9 +2,7 @@ namespace Illuminate\Database\Events; -use Illuminate\Contracts\Database\Events\MigrationEvent as MigrationEventContract; - -class MigrationsEnded implements MigrationEventContract +class MigrationsEnded extends MigrationsEvent { // } diff --git a/src/Illuminate/Database/Events/MigrationsEvent.php b/src/Illuminate/Database/Events/MigrationsEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..c1f465a3c2020dcca6f9c5a68d6c4b3585791856 --- /dev/null +++ b/src/Illuminate/Database/Events/MigrationsEvent.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Database\Events; + +use Illuminate\Contracts\Database\Events\MigrationEvent as MigrationEventContract; + +abstract class MigrationsEvent implements MigrationEventContract +{ + /** + * The migration method that was invoked. + * + * @var string + */ + public $method; + + /** + * Create a new event instance. + * + * @param string $method + * @return void + */ + public function __construct($method) + { + $this->method = $method; + } +} diff --git a/src/Illuminate/Database/Events/MigrationsStarted.php b/src/Illuminate/Database/Events/MigrationsStarted.php index 123e03260f69ed9c86fefc20344489ae8d618043..5283b49916382456191381d041de8f32e0ed0576 100644 --- a/src/Illuminate/Database/Events/MigrationsStarted.php +++ b/src/Illuminate/Database/Events/MigrationsStarted.php @@ -2,9 +2,7 @@ namespace Illuminate\Database\Events; -use Illuminate\Contracts\Database\Events\MigrationEvent as MigrationEventContract; - -class MigrationsStarted implements MigrationEventContract +class MigrationsStarted extends MigrationsEvent { // } diff --git a/src/Illuminate/Database/Events/ModelsPruned.php b/src/Illuminate/Database/Events/ModelsPruned.php new file mode 100644 index 0000000000000000000000000000000000000000..ca8bee9e0f5d27de444e7e1d19336a22531f773d --- /dev/null +++ b/src/Illuminate/Database/Events/ModelsPruned.php @@ -0,0 +1,33 @@ +<?php + +namespace Illuminate\Database\Events; + +class ModelsPruned +{ + /** + * The class name of the model that was pruned. + * + * @var string + */ + public $model; + + /** + * The number of pruned records. + * + * @var int + */ + public $count; + + /** + * Create a new event instance. + * + * @param string $model + * @param int $count + * @return void + */ + public function __construct($model, $count) + { + $this->model = $model; + $this->count = $count; + } +} diff --git a/src/Illuminate/Database/Events/SchemaDumped.php b/src/Illuminate/Database/Events/SchemaDumped.php new file mode 100644 index 0000000000000000000000000000000000000000..1cbbfff96ec6cbc0b121f6b2ab963c9af445408c --- /dev/null +++ b/src/Illuminate/Database/Events/SchemaDumped.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Database\Events; + +class SchemaDumped +{ + /** + * The database connection instance. + * + * @var \Illuminate\Database\Connection + */ + public $connection; + + /** + * The database connection name. + * + * @var string + */ + public $connectionName; + + /** + * The path to the schema dump. + * + * @var string + */ + public $path; + + /** + * Create a new event instance. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + public function __construct($connection, $path) + { + $this->connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/src/Illuminate/Database/Events/SchemaLoaded.php b/src/Illuminate/Database/Events/SchemaLoaded.php new file mode 100644 index 0000000000000000000000000000000000000000..061a079a9611ec127ef897bf0d288cedd3738cd4 --- /dev/null +++ b/src/Illuminate/Database/Events/SchemaLoaded.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Database\Events; + +class SchemaLoaded +{ + /** + * The database connection instance. + * + * @var \Illuminate\Database\Connection + */ + public $connection; + + /** + * The database connection name. + * + * @var string + */ + public $connectionName; + + /** + * The path to the schema dump. + * + * @var string + */ + public $path; + + /** + * Create a new event instance. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + public function __construct($connection, $path) + { + $this->connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/src/Illuminate/Database/Grammar.php b/src/Illuminate/Database/Grammar.php index cc1e0b9469400f5a446d8e90b2779c39eeb78b91..52e3d635753ac805145dd778e31c5d1aa6695f03 100755 --- a/src/Illuminate/Database/Grammar.php +++ b/src/Illuminate/Database/Grammar.php @@ -179,7 +179,7 @@ abstract class Grammar * Get the value of a raw expression. * * @param \Illuminate\Database\Query\Expression $expression - * @return string + * @return mixed */ public function getValue($expression) { diff --git a/src/Illuminate/Database/LazyLoadingViolationException.php b/src/Illuminate/Database/LazyLoadingViolationException.php new file mode 100644 index 0000000000000000000000000000000000000000..1bcd40c95a369507366eaccbd0f408c67fb53f06 --- /dev/null +++ b/src/Illuminate/Database/LazyLoadingViolationException.php @@ -0,0 +1,39 @@ +<?php + +namespace Illuminate\Database; + +use RuntimeException; + +class LazyLoadingViolationException extends RuntimeException +{ + /** + * The name of the affected Eloquent model. + * + * @var string + */ + public $model; + + /** + * The name of the relation. + * + * @var string + */ + public $relation; + + /** + * Create a new exception instance. + * + * @param object $model + * @param string $relation + * @return static + */ + public function __construct($model, $relation) + { + $class = get_class($model); + + parent::__construct("Attempted to lazy load [{$relation}] on model [{$class}] but lazy loading is disabled."); + + $this->model = $class; + $this->relation = $relation; + } +} diff --git a/src/Illuminate/Database/MigrationServiceProvider.php b/src/Illuminate/Database/MigrationServiceProvider.php index efd5f836c8668d5bebc4589ab61cfecb337b3dcd..9ae76857736169a0c5df4324f792f5f778c8d679 100755 --- a/src/Illuminate/Database/MigrationServiceProvider.php +++ b/src/Illuminate/Database/MigrationServiceProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\InstallCommand; @@ -89,7 +90,7 @@ class MigrationServiceProvider extends ServiceProvider implements DeferrableProv protected function registerCreator() { $this->app->singleton('migration.creator', function ($app) { - return new MigrationCreator($app['files']); + return new MigrationCreator($app['files'], $app->basePath('stubs')); }); } @@ -116,7 +117,7 @@ class MigrationServiceProvider extends ServiceProvider implements DeferrableProv protected function registerMigrateCommand() { $this->app->singleton('command.migrate', function ($app) { - return new MigrateCommand($app['migrator']); + return new MigrateCommand($app['migrator'], $app[Dispatcher::class]); }); } diff --git a/src/Illuminate/Database/Migrations/DatabaseMigrationRepository.php b/src/Illuminate/Database/Migrations/DatabaseMigrationRepository.php index 1ace1a6ff7e3fa680dbfc9265245368f39a67bd8..ed42756b1b2f2a628caa42542a9b71d00e7fd3a7 100755 --- a/src/Illuminate/Database/Migrations/DatabaseMigrationRepository.php +++ b/src/Illuminate/Database/Migrations/DatabaseMigrationRepository.php @@ -169,6 +169,18 @@ class DatabaseMigrationRepository implements MigrationRepositoryInterface return $schema->hasTable($this->table); } + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository() + { + $schema = $this->getConnection()->getSchemaBuilder(); + + $schema->drop($this->table); + } + /** * Get a query builder for the migration table. * diff --git a/src/Illuminate/Database/Migrations/MigrationCreator.php b/src/Illuminate/Database/Migrations/MigrationCreator.php index 2a8c69b5a46dac28be39156752c002d5080deda2..a79039a7a20b7950fe5b90c86b0f234f6face21f 100755 --- a/src/Illuminate/Database/Migrations/MigrationCreator.php +++ b/src/Illuminate/Database/Migrations/MigrationCreator.php @@ -16,6 +16,13 @@ class MigrationCreator */ protected $files; + /** + * The custom app stubs directory. + * + * @var string + */ + protected $customStubPath; + /** * The registered post create hooks. * @@ -27,11 +34,13 @@ class MigrationCreator * Create a new migration creator instance. * * @param \Illuminate\Filesystem\Filesystem $files + * @param string $customStubPath * @return void */ - public function __construct(Filesystem $files) + public function __construct(Filesystem $files, $customStubPath) { $this->files = $files; + $this->customStubPath = $customStubPath; } /** @@ -54,9 +63,12 @@ class MigrationCreator // various place-holders, save the file, and run the post create event. $stub = $this->getStub($table, $create); + $path = $this->getPath($name, $path); + + $this->files->ensureDirectoryExists(dirname($path)); + $this->files->put( - $path = $this->getPath($name, $path), - $this->populateStub($name, $stub, $table) + $path, $this->populateStub($name, $stub, $table) ); // Next, we will fire any hooks that are supposed to fire after a migration is @@ -101,15 +113,20 @@ class MigrationCreator protected function getStub($table, $create) { if (is_null($table)) { - return $this->files->get($this->stubPath().'/blank.stub'); + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.stub') + ? $customPath + : $this->stubPath().'/migration.stub'; + } elseif ($create) { + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.create.stub') + ? $customPath + : $this->stubPath().'/migration.create.stub'; + } else { + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.update.stub') + ? $customPath + : $this->stubPath().'/migration.update.stub'; } - // We also have stubs for creating new tables and modifying existing tables - // to save the developer some typing when they are creating a new tables - // or modifying existing tables. We'll grab the appropriate stub here. - $stub = $create ? 'create.stub' : 'update.stub'; - - return $this->files->get($this->stubPath()."/{$stub}"); + return $this->files->get($stub); } /** @@ -122,13 +139,19 @@ class MigrationCreator */ protected function populateStub($name, $stub, $table) { - $stub = str_replace('DummyClass', $this->getClassName($name), $stub); + $stub = str_replace( + ['DummyClass', '{{ class }}', '{{class}}'], + $this->getClassName($name), $stub + ); // Here we will replace the table place-holders with the table specified by // the developer, which is useful for quickly creating a tables creation // or update migration from the console instead of typing it manually. if (! is_null($table)) { - $stub = str_replace('DummyTable', $table, $stub); + $stub = str_replace( + ['DummyTable', '{{ table }}', '{{table}}'], + $table, $stub + ); } return $stub; diff --git a/src/Illuminate/Database/Migrations/MigrationRepositoryInterface.php b/src/Illuminate/Database/Migrations/MigrationRepositoryInterface.php index 410326a9bd6841d40a11bfaebb3dc8a8a100236f..840a5e1dfce1bf265c14fd354b7be40209117371 100755 --- a/src/Illuminate/Database/Migrations/MigrationRepositoryInterface.php +++ b/src/Illuminate/Database/Migrations/MigrationRepositoryInterface.php @@ -12,7 +12,7 @@ interface MigrationRepositoryInterface public function getRan(); /** - * Get list of migrations. + * Get the list of migrations. * * @param int $steps * @return array @@ -71,6 +71,13 @@ interface MigrationRepositoryInterface */ public function repositoryExists(); + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository(); + /** * Set the information source to gather data. * diff --git a/src/Illuminate/Database/Migrations/Migrator.php b/src/Illuminate/Database/Migrations/Migrator.php index 733c08d2529779a2bc87af520db68f00beee90a7..c043e6cd735f38e95889fc4a5cb9ab51abaae49a 100755 --- a/src/Illuminate/Database/Migrations/Migrator.php +++ b/src/Illuminate/Database/Migrations/Migrator.php @@ -2,7 +2,7 @@ namespace Illuminate\Database\Migrations; -use Illuminate\Console\OutputStyle; +use Doctrine\DBAL\Schema\SchemaException; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Events\MigrationEnded; @@ -14,6 +14,8 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use ReflectionClass; +use Symfony\Component\Console\Output\OutputInterface; class Migrator { @@ -62,7 +64,7 @@ class Migrator /** * The output interface implementation. * - * @var \Illuminate\Console\OutputStyle + * @var \Symfony\Component\Console\Output\OutputInterface */ protected $output; @@ -156,7 +158,7 @@ class Migrator $step = $options['step'] ?? false; - $this->fireMigrationEvent(new MigrationsStarted); + $this->fireMigrationEvent(new MigrationsStarted('up')); // Once we have the array of migrations, we will spin through them and run the // migrations "up" so the changes are made to the databases. We'll then log @@ -169,7 +171,7 @@ class Migrator } } - $this->fireMigrationEvent(new MigrationsEnded); + $this->fireMigrationEvent(new MigrationsEnded('up')); } /** @@ -185,9 +187,9 @@ class Migrator // First we will resolve a "real" instance of the migration class from this // migration file name. Once we have the instances we can run the actual // command such as "up" or "down", or we can just simulate the action. - $migration = $this->resolve( - $name = $this->getMigrationName($file) - ); + $migration = $this->resolvePath($file); + + $name = $this->getMigrationName($file); if ($pretend) { return $this->pretendToRun($migration, 'up'); @@ -199,14 +201,14 @@ class Migrator $this->runMigration($migration, 'up'); - $runTime = round(microtime(true) - $startTime, 2); + $runTime = number_format((microtime(true) - $startTime) * 1000, 2); // Once we have run a migrations class, we will log that it was run in this // repository so that we don't try to run it next time we do a migration // in the application. A migration repository keeps the migrate order. $this->repository->log($name, $batch); - $this->note("<info>Migrated:</info> {$name} ({$runTime} seconds)"); + $this->note("<info>Migrated:</info> {$name} ({$runTime}ms)"); } /** @@ -263,7 +265,7 @@ class Migrator $this->requireFiles($files = $this->getMigrationFiles($paths)); - $this->fireMigrationEvent(new MigrationsStarted); + $this->fireMigrationEvent(new MigrationsStarted('down')); // Next we will run through all of the migrations and call the "down" method // which will reverse each migration in order. This getLast method on the @@ -285,7 +287,7 @@ class Migrator ); } - $this->fireMigrationEvent(new MigrationsEnded); + $this->fireMigrationEvent(new MigrationsEnded('down')); return $rolledBack; } @@ -348,9 +350,9 @@ class Migrator // First we will get the file name of the migration so we can resolve out an // instance of the migration. Once we get an instance we can either run a // pretend execution of the migration or we can run the real migration. - $instance = $this->resolve( - $name = $this->getMigrationName($file) - ); + $instance = $this->resolvePath($file); + + $name = $this->getMigrationName($file); $this->note("<comment>Rolling back:</comment> {$name}"); @@ -362,14 +364,14 @@ class Migrator $this->runMigration($instance, 'down'); - $runTime = round(microtime(true) - $startTime, 2); + $runTime = number_format((microtime(true) - $startTime) * 1000, 2); // Once we have successfully run the migration "down" we will remove it from // the migration repository so it will be considered to have not been run // by the application then will be able to fire by any later operation. $this->repository->delete($migration); - $this->note("<info>Rolled back:</info> {$name} ({$runTime} seconds)"); + $this->note("<info>Rolled back:</info> {$name} ({$runTime}ms)"); } /** @@ -385,11 +387,11 @@ class Migrator $migration->getConnection() ); - $callback = function () use ($migration, $method) { + $callback = function () use ($connection, $migration, $method) { if (method_exists($migration, $method)) { $this->fireMigrationEvent(new MigrationStarted($migration, $method)); - $migration->{$method}(); + $this->runMethod($connection, $migration, $method); $this->fireMigrationEvent(new MigrationEnded($migration, $method)); } @@ -410,10 +412,22 @@ class Migrator */ protected function pretendToRun($migration, $method) { - foreach ($this->getQueries($migration, $method) as $query) { + try { + foreach ($this->getQueries($migration, $method) as $query) { + $name = get_class($migration); + + $reflectionClass = new ReflectionClass($migration); + + if ($reflectionClass->isAnonymous()) { + $name = $this->getMigrationName($reflectionClass->getFileName()); + } + + $this->note("<info>{$name}:</info> {$query['query']}"); + } + } catch (SchemaException $e) { $name = get_class($migration); - $this->note("<info>{$name}:</info> {$query['query']}"); + $this->note("<info>{$name}:</info> failed to dump queries. This may be due to changing database columns using Doctrine, which is not supported while pretending to run migrations."); } } @@ -433,13 +447,34 @@ class Migrator $migration->getConnection() ); - return $db->pretend(function () use ($migration, $method) { + return $db->pretend(function () use ($db, $migration, $method) { if (method_exists($migration, $method)) { - $migration->{$method}(); + $this->runMethod($db, $migration, $method); } }); } + /** + * Run a migration method on the given connection. + * + * @param \Illuminate\Database\Connection $connection + * @param object $migration + * @param string $method + * @return void + */ + protected function runMethod($connection, $migration, $method) + { + $previousConnection = $this->resolver->getDefaultConnection(); + + try { + $this->resolver->setDefaultConnection($connection->getName()); + + $migration->{$method}(); + } finally { + $this->resolver->setDefaultConnection($previousConnection); + } + } + /** * Resolve a migration instance from a file. * @@ -448,11 +483,41 @@ class Migrator */ public function resolve($file) { - $class = Str::studly(implode('_', array_slice(explode('_', $file), 4))); + $class = $this->getMigrationClass($file); return new $class; } + /** + * Resolve a migration instance from a migration path. + * + * @param string $path + * @return object + */ + protected function resolvePath(string $path) + { + $class = $this->getMigrationClass($this->getMigrationName($path)); + + if (class_exists($class) && realpath($path) == (new ReflectionClass($class))->getFileName()) { + return new $class; + } + + $migration = $this->files->getRequire($path); + + return is_object($migration) ? $migration : new $class; + } + + /** + * Generate a migration class name based on the migration file name. + * + * @param string $migrationName + * @return string + */ + protected function getMigrationClass(string $migrationName): string + { + return Str::studly(implode('_', array_slice(explode('_', $migrationName), 4))); + } + /** * Get all of the migration files in a given path. * @@ -525,6 +590,24 @@ class Migrator return $this->connection; } + /** + * Execute the given callback using the given connection as the default connection. + * + * @param string $name + * @param callable $callback + * @return mixed + */ + public function usingConnection($name, callable $callback) + { + $previousConnection = $this->resolver->getDefaultConnection(); + + $this->setConnection($name); + + return tap($callback(), function () use ($previousConnection) { + $this->setConnection($previousConnection); + }); + } + /** * Set the default connection name. * @@ -590,6 +673,26 @@ class Migrator return $this->repository->repositoryExists(); } + /** + * Determine if any migrations have been run. + * + * @return bool + */ + public function hasRunAnyMigrations() + { + return $this->repositoryExists() && count($this->repository->getRan()) > 0; + } + + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository() + { + return $this->repository->deleteRepository(); + } + /** * Get the file system instance. * @@ -603,10 +706,10 @@ class Migrator /** * Set the output implementation that should be used by the console. * - * @param \Illuminate\Console\OutputStyle $output + * @param \Symfony\Component\Console\Output\OutputInterface $output * @return $this */ - public function setOutput(OutputStyle $output) + public function setOutput(OutputInterface $output) { $this->output = $output; diff --git a/src/Illuminate/Database/Migrations/stubs/create.stub b/src/Illuminate/Database/Migrations/stubs/migration.create.stub similarity index 68% rename from src/Illuminate/Database/Migrations/stubs/create.stub rename to src/Illuminate/Database/Migrations/stubs/migration.create.stub index a6321c2b5409a5d2713fd9812bb4dc4cfebbbea5..f4a56a0774dd3cbe107edba90823aaaa4a93918d 100755 --- a/src/Illuminate/Database/Migrations/stubs/create.stub +++ b/src/Illuminate/Database/Migrations/stubs/migration.create.stub @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class DummyClass extends Migration +class {{ class }} extends Migration { /** * Run the migrations. @@ -13,8 +13,8 @@ class DummyClass extends Migration */ public function up() { - Schema::create('DummyTable', function (Blueprint $table) { - $table->bigIncrements('id'); + Schema::create('{{ table }}', function (Blueprint $table) { + $table->id(); $table->timestamps(); }); } @@ -26,6 +26,6 @@ class DummyClass extends Migration */ public function down() { - Schema::dropIfExists('DummyTable'); + Schema::dropIfExists('{{ table }}'); } } diff --git a/src/Illuminate/Database/Migrations/stubs/blank.stub b/src/Illuminate/Database/Migrations/stubs/migration.stub similarity index 91% rename from src/Illuminate/Database/Migrations/stubs/blank.stub rename to src/Illuminate/Database/Migrations/stubs/migration.stub index 5e3b1540f75621acc602ba33fc3cd1779957d737..fd0e4378566d745c3de2359f637cc2c1d93e28ed 100755 --- a/src/Illuminate/Database/Migrations/stubs/blank.stub +++ b/src/Illuminate/Database/Migrations/stubs/migration.stub @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class DummyClass extends Migration +class {{ class }} extends Migration { /** * Run the migrations. diff --git a/src/Illuminate/Database/Migrations/stubs/update.stub b/src/Illuminate/Database/Migrations/stubs/migration.update.stub similarity index 70% rename from src/Illuminate/Database/Migrations/stubs/update.stub rename to src/Illuminate/Database/Migrations/stubs/migration.update.stub index 9ee9bfabea4f775ff9c6adbb3111f50197ef2ae2..f1a04ebe54d727588c359faa3d8d9b973931e563 100755 --- a/src/Illuminate/Database/Migrations/stubs/update.stub +++ b/src/Illuminate/Database/Migrations/stubs/migration.update.stub @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class DummyClass extends Migration +class {{ class }} extends Migration { /** * Run the migrations. @@ -13,7 +13,7 @@ class DummyClass extends Migration */ public function up() { - Schema::table('DummyTable', function (Blueprint $table) { + Schema::table('{{ table }}', function (Blueprint $table) { // }); } @@ -25,7 +25,7 @@ class DummyClass extends Migration */ public function down() { - Schema::table('DummyTable', function (Blueprint $table) { + Schema::table('{{ table }}', function (Blueprint $table) { // }); } diff --git a/src/Illuminate/Database/MultipleRecordsFoundException.php b/src/Illuminate/Database/MultipleRecordsFoundException.php new file mode 100755 index 0000000000000000000000000000000000000000..cccb7e4177bf52d9fa56166bb32d635e6368c80f --- /dev/null +++ b/src/Illuminate/Database/MultipleRecordsFoundException.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Database; + +use RuntimeException; + +class MultipleRecordsFoundException extends RuntimeException +{ + // +} diff --git a/src/Illuminate/Database/MySqlConnection.php b/src/Illuminate/Database/MySqlConnection.php index 94b5b57d87e08dc85ea7716904cd578ac44ff013..9760358cf5f4dfaad53e139814d1aa40a3b3afd3 100755 --- a/src/Illuminate/Database/MySqlConnection.php +++ b/src/Illuminate/Database/MySqlConnection.php @@ -3,13 +3,28 @@ namespace Illuminate\Database; use Doctrine\DBAL\Driver\PDOMySql\Driver as DoctrineDriver; +use Doctrine\DBAL\Version; +use Illuminate\Database\PDO\MySqlDriver; use Illuminate\Database\Query\Grammars\MySqlGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\MySqlProcessor; use Illuminate\Database\Schema\Grammars\MySqlGrammar as SchemaGrammar; use Illuminate\Database\Schema\MySqlBuilder; +use Illuminate\Database\Schema\MySqlSchemaState; +use Illuminate\Filesystem\Filesystem; +use PDO; class MySqlConnection extends Connection { + /** + * Determine if the connected database is a MariaDB database. + * + * @return bool + */ + public function isMaria() + { + return strpos($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB') !== false; + } + /** * Get the default query grammar instance. * @@ -44,6 +59,18 @@ class MySqlConnection extends Connection return $this->withTablePrefix(new SchemaGrammar); } + /** + * Get the schema state for the connection. + * + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return \Illuminate\Database\Schema\MySqlSchemaState + */ + public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + { + return new MySqlSchemaState($this, $files, $processFactory); + } + /** * Get the default post processor instance. * @@ -57,10 +84,10 @@ class MySqlConnection extends Connection /** * Get the Doctrine DBAL driver. * - * @return \Doctrine\DBAL\Driver\PDOMySql\Driver + * @return \Doctrine\DBAL\Driver\PDOMySql\Driver|\Illuminate\Database\PDO\MySqlDriver */ protected function getDoctrineDriver() { - return new DoctrineDriver; + return class_exists(Version::class) ? new DoctrineDriver : new MySqlDriver; } } diff --git a/src/Illuminate/Database/PDO/Concerns/ConnectsToDatabase.php b/src/Illuminate/Database/PDO/Concerns/ConnectsToDatabase.php new file mode 100644 index 0000000000000000000000000000000000000000..84c33380139ddf770380db548e3c92b8d4cfcfdc --- /dev/null +++ b/src/Illuminate/Database/PDO/Concerns/ConnectsToDatabase.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Database\PDO\Concerns; + +use Illuminate\Database\PDO\Connection; +use InvalidArgumentException; +use PDO; + +trait ConnectsToDatabase +{ + /** + * Create a new database connection. + * + * @param array $params + * @return \Illuminate\Database\PDO\Connection + * + * @throws \InvalidArgumentException + */ + public function connect(array $params) + { + if (! isset($params['pdo']) || ! $params['pdo'] instanceof PDO) { + throw new InvalidArgumentException('Laravel requires the "pdo" property to be set and be a PDO instance.'); + } + + return new Connection($params['pdo']); + } +} diff --git a/src/Illuminate/Database/PDO/Connection.php b/src/Illuminate/Database/PDO/Connection.php new file mode 100644 index 0000000000000000000000000000000000000000..7bae4cc049486aef61af47fe346bf9bb3f92e0de --- /dev/null +++ b/src/Illuminate/Database/PDO/Connection.php @@ -0,0 +1,182 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\PDO\Exception; +use Doctrine\DBAL\Driver\PDO\Result; +use Doctrine\DBAL\Driver\PDO\Statement; +use Doctrine\DBAL\Driver\Result as ResultInterface; +use Doctrine\DBAL\Driver\ServerInfoAwareConnection; +use Doctrine\DBAL\Driver\Statement as StatementInterface; +use Doctrine\DBAL\ParameterType; +use PDO; +use PDOException; +use PDOStatement; + +class Connection implements ServerInfoAwareConnection +{ + /** + * The underlying PDO connection. + * + * @var \PDO + */ + protected $connection; + + /** + * Create a new PDO connection instance. + * + * @param \PDO $connection + * @return void + */ + public function __construct(PDO $connection) + { + $this->connection = $connection; + } + + /** + * Execute an SQL statement. + * + * @param string $statement + * @return int + */ + public function exec(string $statement): int + { + try { + $result = $this->connection->exec($statement); + + \assert($result !== false); + + return $result; + } catch (PDOException $exception) { + throw Exception::new($exception); + } + } + + /** + * Prepare a new SQL statement. + * + * @param string $sql + * @return \Doctrine\DBAL\Driver\Statement + */ + public function prepare(string $sql): StatementInterface + { + try { + return $this->createStatement( + $this->connection->prepare($sql) + ); + } catch (PDOException $exception) { + throw Exception::new($exception); + } + } + + /** + * Execute a new query against the connection. + * + * @param string $sql + * @return \Doctrine\DBAL\Driver\Result + */ + public function query(string $sql): ResultInterface + { + try { + $stmt = $this->connection->query($sql); + + \assert($stmt instanceof PDOStatement); + + return new Result($stmt); + } catch (PDOException $exception) { + throw Exception::new($exception); + } + } + + /** + * Get the last insert ID. + * + * @param string|null $name + * @return mixed + */ + public function lastInsertId($name = null) + { + try { + if ($name === null) { + return $this->connection->lastInsertId(); + } + + return $this->connection->lastInsertId($name); + } catch (PDOException $exception) { + throw Exception::new($exception); + } + } + + /** + * Create a new statement instance. + * + * @param \PDOStatement $stmt + * @return \Doctrine\DBAL\Driver\PDO\Statement + */ + protected function createStatement(PDOStatement $stmt): Statement + { + return new Statement($stmt); + } + + /** + * Begin a new database transaction. + * + * @return void + */ + public function beginTransaction() + { + return $this->connection->beginTransaction(); + } + + /** + * Commit a database transaction. + * + * @return void + */ + public function commit() + { + return $this->connection->commit(); + } + + /** + * Rollback a database transaction. + * + * @return void + */ + public function rollBack() + { + return $this->connection->rollBack(); + } + + /** + * Wrap quotes around the given input. + * + * @param string $input + * @param string $type + * @return string + */ + public function quote($input, $type = ParameterType::STRING) + { + return $this->connection->quote($input, $type); + } + + /** + * Get the server version for the connection. + * + * @return string + */ + public function getServerVersion() + { + return $this->connection->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + /** + * Get the wrapped PDO connection. + * + * @return \PDO + */ + public function getWrappedConnection(): PDO + { + return $this->connection; + } +} diff --git a/src/Illuminate/Database/PDO/MySqlDriver.php b/src/Illuminate/Database/PDO/MySqlDriver.php new file mode 100644 index 0000000000000000000000000000000000000000..5f68c6fab5a460e2d297940a0869b22c4beeceb3 --- /dev/null +++ b/src/Illuminate/Database/PDO/MySqlDriver.php @@ -0,0 +1,11 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\AbstractMySQLDriver; +use Illuminate\Database\PDO\Concerns\ConnectsToDatabase; + +class MySqlDriver extends AbstractMySQLDriver +{ + use ConnectsToDatabase; +} diff --git a/src/Illuminate/Database/PDO/PostgresDriver.php b/src/Illuminate/Database/PDO/PostgresDriver.php new file mode 100644 index 0000000000000000000000000000000000000000..eb29c969de587b5decb39bc6a084ece5c52fd2aa --- /dev/null +++ b/src/Illuminate/Database/PDO/PostgresDriver.php @@ -0,0 +1,11 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver; +use Illuminate\Database\PDO\Concerns\ConnectsToDatabase; + +class PostgresDriver extends AbstractPostgreSQLDriver +{ + use ConnectsToDatabase; +} diff --git a/src/Illuminate/Database/PDO/SQLiteDriver.php b/src/Illuminate/Database/PDO/SQLiteDriver.php new file mode 100644 index 0000000000000000000000000000000000000000..2dac06db8be0309ba3148600b06de511b16faade --- /dev/null +++ b/src/Illuminate/Database/PDO/SQLiteDriver.php @@ -0,0 +1,11 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\AbstractSQLiteDriver; +use Illuminate\Database\PDO\Concerns\ConnectsToDatabase; + +class SQLiteDriver extends AbstractSQLiteDriver +{ + use ConnectsToDatabase; +} diff --git a/src/Illuminate/Database/PDO/SqlServerConnection.php b/src/Illuminate/Database/PDO/SqlServerConnection.php new file mode 100644 index 0000000000000000000000000000000000000000..bbbb8b0b6972afb3a77c361804ab5ac3ce6c7528 --- /dev/null +++ b/src/Illuminate/Database/PDO/SqlServerConnection.php @@ -0,0 +1,152 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\PDO\SQLSrv\Statement; +use Doctrine\DBAL\Driver\Result; +use Doctrine\DBAL\Driver\ServerInfoAwareConnection; +use Doctrine\DBAL\Driver\Statement as StatementInterface; +use Doctrine\DBAL\ParameterType; +use PDO; + +class SqlServerConnection implements ServerInfoAwareConnection +{ + /** + * The underlying connection instance. + * + * @var \Illuminate\Database\PDO\Connection + */ + protected $connection; + + /** + * Create a new SQL Server connection instance. + * + * @param \Illuminate\Database\PDO\Connection $connection + * @return void + */ + public function __construct(Connection $connection) + { + $this->connection = $connection; + } + + /** + * Prepare a new SQL statement. + * + * @param string $sql + * @return \Doctrine\DBAL\Driver\Statement + */ + public function prepare(string $sql): StatementInterface + { + return new Statement( + $this->connection->prepare($sql) + ); + } + + /** + * Execute a new query against the connection. + * + * @param string $sql + * @return \Doctrine\DBAL\Driver\Result + */ + public function query(string $sql): Result + { + return $this->connection->query($sql); + } + + /** + * Execute an SQL statement. + * + * @param string $statement + * @return int + */ + public function exec(string $statement): int + { + return $this->connection->exec($statement); + } + + /** + * Get the last insert ID. + * + * @param string|null $name + * @return mixed + */ + public function lastInsertId($name = null) + { + if ($name === null) { + return $this->connection->lastInsertId($name); + } + + return $this->prepare('SELECT CONVERT(VARCHAR(MAX), current_value) FROM sys.sequences WHERE name = ?') + ->execute([$name]) + ->fetchOne(); + } + + /** + * Begin a new database transaction. + * + * @return void + */ + public function beginTransaction() + { + return $this->connection->beginTransaction(); + } + + /** + * Commit a database transaction. + * + * @return void + */ + public function commit() + { + return $this->connection->commit(); + } + + /** + * Rollback a database transaction. + * + * @return void + */ + public function rollBack() + { + return $this->connection->rollBack(); + } + + /** + * Wrap quotes around the given input. + * + * @param string $value + * @param int $type + * @return string + */ + public function quote($value, $type = ParameterType::STRING) + { + $val = $this->connection->quote($value, $type); + + // Fix for a driver version terminating all values with null byte... + if (\is_string($val) && \strpos($val, "\0") !== false) { + $val = \substr($val, 0, -1); + } + + return $val; + } + + /** + * Get the server version for the connection. + * + * @return string + */ + public function getServerVersion() + { + return $this->connection->getServerVersion(); + } + + /** + * Get the wrapped PDO connection. + * + * @return \PDO + */ + public function getWrappedConnection(): PDO + { + return $this->connection->getWrappedConnection(); + } +} diff --git a/src/Illuminate/Database/PDO/SqlServerDriver.php b/src/Illuminate/Database/PDO/SqlServerDriver.php new file mode 100644 index 0000000000000000000000000000000000000000..1373fc49f6e0ea659779f48c004deffec58aaa60 --- /dev/null +++ b/src/Illuminate/Database/PDO/SqlServerDriver.php @@ -0,0 +1,18 @@ +<?php + +namespace Illuminate\Database\PDO; + +use Doctrine\DBAL\Driver\AbstractSQLServerDriver; + +class SqlServerDriver extends AbstractSQLServerDriver +{ + /** + * @return \Doctrine\DBAL\Driver\Connection + */ + public function connect(array $params) + { + return new SqlServerConnection( + new Connection($params['pdo']) + ); + } +} diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index 5555df1a2e32b30b082769e3436a1459b78d722a..5d68d1d665a75bd2c995efb0090089379490d23b 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -3,13 +3,44 @@ namespace Illuminate\Database; use Doctrine\DBAL\Driver\PDOPgSql\Driver as DoctrineDriver; +use Doctrine\DBAL\Version; +use Illuminate\Database\PDO\PostgresDriver; use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\PostgresProcessor; use Illuminate\Database\Schema\Grammars\PostgresGrammar as SchemaGrammar; use Illuminate\Database\Schema\PostgresBuilder; +use Illuminate\Database\Schema\PostgresSchemaState; +use Illuminate\Filesystem\Filesystem; +use PDO; class PostgresConnection extends Connection { + /** + * Bind values to their parameters in the given statement. + * + * @param \PDOStatement $statement + * @param array $bindings + * @return void + */ + public function bindValues($statement, $bindings) + { + foreach ($bindings as $key => $value) { + if (is_int($value)) { + $pdoParam = PDO::PARAM_INT; + } elseif (is_resource($value)) { + $pdoParam = PDO::PARAM_LOB; + } else { + $pdoParam = PDO::PARAM_STR; + } + + $statement->bindValue( + is_string($key) ? $key : $key + 1, + $value, + $pdoParam + ); + } + } + /** * Get the default query grammar instance. * @@ -44,6 +75,18 @@ class PostgresConnection extends Connection return $this->withTablePrefix(new SchemaGrammar); } + /** + * Get the schema state for the connection. + * + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return \Illuminate\Database\Schema\PostgresSchemaState + */ + public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + { + return new PostgresSchemaState($this, $files, $processFactory); + } + /** * Get the default post processor instance. * @@ -57,10 +100,10 @@ class PostgresConnection extends Connection /** * Get the Doctrine DBAL driver. * - * @return \Doctrine\DBAL\Driver\PDOPgSql\Driver + * @return \Doctrine\DBAL\Driver\PDOPgSql\Driver|\Illuminate\Database\PDO\PostgresDriver */ protected function getDoctrineDriver() { - return new DoctrineDriver; + return class_exists(Version::class) ? new DoctrineDriver : new PostgresDriver; } } diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index c762e8525605505b593ed870c1a22cc967bcebec..40bd0b9589c4dd9cd613d11b3056e2b851227ef0 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -2,12 +2,15 @@ namespace Illuminate\Database\Query; +use BackedEnum; use Closure; use DateTimeInterface; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; +use Illuminate\Database\Concerns\ExplainsQueries; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Query\Processors\Processor; use Illuminate\Pagination\Paginator; @@ -18,11 +21,12 @@ use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; +use LogicException; use RuntimeException; class Builder { - use BuildsQueries, ForwardsCalls, Macroable { + use BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { __call as macroCall; } @@ -179,21 +183,37 @@ class Builder public $lock; /** - * All of the available clause operators. + * The callbacks that should be invoked before the query is executed. * * @var array */ + public $beforeQueryCallbacks = []; + + /** + * All of the available clause operators. + * + * @var string[] + */ public $operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', '<=>', 'like', 'like binary', 'not like', 'ilike', - '&', '|', '^', '<<', '>>', + '&', '|', '^', '<<', '>>', '&~', 'rlike', 'not rlike', 'regexp', 'not regexp', '~', '~*', '!~', '!~*', 'similar to', 'not similar to', 'not ilike', '~~*', '!~~*', ]; /** - * Whether use write pdo for select. + * All of the available bitwise operators. + * + * @var string[] + */ + public $bitwiseOperators = [ + '&', '|', '^', '<<', '>>', '&~', + ]; + + /** + * Whether to use write pdo for the select. * * @var bool */ @@ -225,7 +245,7 @@ class Builder public function select($columns = ['*']) { $this->columns = []; - + $this->bindings['select'] = []; $columns = is_array($columns) ? $columns : func_get_args(); foreach ($columns as $as => $column) { @@ -242,9 +262,9 @@ class Builder /** * Add a subselect expression to the query. * - * @param \Closure|$this|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as - * @return \Illuminate\Database\Query\Builder|static + * @return $this * * @throws \InvalidArgumentException */ @@ -262,7 +282,7 @@ class Builder * * @param string $expression * @param array $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function selectRaw($expression, array $bindings = []) { @@ -280,7 +300,7 @@ class Builder * * @param \Closure|\Illuminate\Database\Query\Builder|string $query * @param string $as - * @return \Illuminate\Database\Query\Builder|static + * @return $this * * @throws \InvalidArgumentException */ @@ -296,7 +316,7 @@ class Builder * * @param string $expression * @param mixed $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function fromRaw($expression, $bindings = []) { @@ -337,7 +357,9 @@ class Builder */ protected function parseSub($query) { - if ($query instanceof self || $query instanceof EloquentBuilder) { + if ($query instanceof self || $query instanceof EloquentBuilder || $query instanceof Relation) { + $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); + return [$query->toSql(), $query->getBindings()]; } elseif (is_string($query)) { return [$query, []]; @@ -348,6 +370,26 @@ class Builder } } + /** + * Prepend the database name if the given query is on another database. + * + * @param mixed $query + * @return mixed + */ + protected function prependDatabaseNameIfCrossDatabaseQuery($query) + { + if ($query->getConnection()->getDatabaseName() !== + $this->getConnection()->getDatabaseName()) { + $databaseName = $query->getConnection()->getDatabaseName(); + + if (strpos($query->from, $databaseName) !== 0 && strpos($query->from, '.') === false) { + $query->from($databaseName.'.'.$query->from); + } + } + + return $query; + } + /** * Add a new select column to the query. * @@ -376,6 +418,7 @@ class Builder /** * Force the query to only return distinct results. * + * @param mixed ...$distinct * @return $this */ public function distinct() @@ -457,7 +500,7 @@ class Builder * @param string $operator * @param string $second * @param string $type - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function joinWhere($table, $first, $operator, $second, $type = 'inner') { @@ -467,14 +510,14 @@ class Builder /** * Add a subquery join clause to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as * @param \Closure|string $first * @param string|null $operator * @param string|null $second * @param string $type * @param bool $where - * @return \Illuminate\Database\Query\Builder|static + * @return $this * * @throws \InvalidArgumentException */ @@ -496,7 +539,7 @@ class Builder * @param \Closure|string $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function leftJoin($table, $first, $operator = null, $second = null) { @@ -510,7 +553,7 @@ class Builder * @param \Closure|string $first * @param string $operator * @param string $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function leftJoinWhere($table, $first, $operator, $second) { @@ -520,12 +563,12 @@ class Builder /** * Add a subquery left join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as * @param \Closure|string $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function leftJoinSub($query, $as, $first, $operator = null, $second = null) { @@ -539,7 +582,7 @@ class Builder * @param \Closure|string $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function rightJoin($table, $first, $operator = null, $second = null) { @@ -553,7 +596,7 @@ class Builder * @param \Closure|string $first * @param string $operator * @param string $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function rightJoinWhere($table, $first, $operator, $second) { @@ -563,12 +606,12 @@ class Builder /** * Add a subquery right join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query * @param string $as * @param \Closure|string $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function rightJoinSub($query, $as, $first, $operator = null, $second = null) { @@ -582,7 +625,7 @@ class Builder * @param \Closure|string|null $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function crossJoin($table, $first = null, $operator = null, $second = null) { @@ -595,6 +638,26 @@ class Builder return $this; } + /** + * Add a subquery cross join to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param string $as + * @return $this + */ + public function crossJoinSub($query, $as) + { + [$query, $bindings] = $this->createSub($query); + + $expression = '('.$query.') as '.$this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + $this->joins[] = $this->newJoinClause($this, 'cross', new Expression($expression)); + + return $this; + } + /** * Get a new join clause. * @@ -652,10 +715,20 @@ class Builder // If the columns is actually a Closure instance, we will assume the developer // wants to begin a nested where statement which is wrapped in parenthesis. // We'll add that Closure to the query then return back out immediately. - if ($column instanceof Closure) { + if ($column instanceof Closure && is_null($operator)) { return $this->whereNested($column, $boolean); } + // If the column is a Closure instance and there is an operator value, we will + // assume the developer wants to run a subquery and then compare the result + // of that subquery with the given value that was provided to the method. + if ($this->isQueryable($column) && ! is_null($operator)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->where(new Expression('('.$sub.')'), $operator, $value, $boolean); + } + // If the given operator is not found in the list of valid operators we will // assume that the developer is just short-cutting the '=' operators and // we will set the operators to '=' and set the values appropriately. @@ -690,6 +763,10 @@ class Builder } } + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; + } + // Now that we are working with just a simple query we can put the elements // in our array and add the query binding to our array of bindings that // will be bound to each SQL statements when it is finally executed. @@ -769,8 +846,20 @@ class Builder */ protected function invalidOperator($operator) { - return ! in_array(strtolower($operator), $this->operators, true) && - ! in_array(strtolower($operator), $this->grammar->getOperators(), true); + return ! is_string($operator) || (! in_array(strtolower($operator), $this->operators, true) && + ! in_array(strtolower($operator), $this->grammar->getOperators(), true)); + } + + /** + * Determine if the operator is a bitwise operator. + * + * @param string $operator + * @return bool + */ + protected function isBitwiseOperator($operator) + { + return in_array(strtolower($operator), $this->bitwiseOperators, true) || + in_array(strtolower($operator), $this->grammar->getBitwiseOperators(), true); } /** @@ -779,7 +868,7 @@ class Builder * @param \Closure|string|array $column * @param mixed $operator * @param mixed $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhere($column, $operator = null, $value = null) { @@ -797,7 +886,7 @@ class Builder * @param string|null $operator * @param string|null $second * @param string|null $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') { @@ -833,7 +922,7 @@ class Builder * @param string|array $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereColumn($first, $operator = null, $second = null) { @@ -862,7 +951,7 @@ class Builder * * @param string $sql * @param mixed $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereRaw($sql, $bindings = []) { @@ -915,7 +1004,7 @@ class Builder * * @param string $column * @param mixed $values - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereIn($column, $values) { @@ -928,7 +1017,7 @@ class Builder * @param string $column * @param mixed $values * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNotIn($column, $values, $boolean = 'and') { @@ -940,7 +1029,7 @@ class Builder * * @param string $column * @param mixed $values - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereNotIn($column, $values) { @@ -973,6 +1062,18 @@ class Builder return $this; } + /** + * Add an "or where in raw" clause for integer values to the query. + * + * @param string $column + * @param \Illuminate\Contracts\Support\Arrayable|array $values + * @return $this + */ + public function orWhereIntegerInRaw($column, $values) + { + return $this->whereIntegerInRaw($column, $values, 'or'); + } + /** * Add a "where not in raw" clause for integer values to the query. * @@ -986,6 +1087,18 @@ class Builder return $this->whereIntegerInRaw($column, $values, $boolean, true); } + /** + * Add an "or where not in raw" clause for integer values to the query. + * + * @param string $column + * @param \Illuminate\Contracts\Support\Arrayable|array $values + * @return $this + */ + public function orWhereIntegerNotInRaw($column, $values) + { + return $this->whereIntegerNotInRaw($column, $values, 'or'); + } + /** * Add a "where null" clause to the query. * @@ -1008,8 +1121,8 @@ class Builder /** * Add an "or where null" clause to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param string|array $column + * @return $this */ public function orWhereNull($column) { @@ -1021,7 +1134,7 @@ class Builder * * @param string|array $columns * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNotNull($columns, $boolean = 'and') { @@ -1031,7 +1144,7 @@ class Builder /** * Add a where between statement to the query. * - * @param string $column + * @param string|\Illuminate\Database\Query\Expression $column * @param array $values * @param string $boolean * @param bool $not @@ -1048,48 +1161,103 @@ class Builder return $this; } + /** + * Add a where between statement using columns to the query. + * + * @param string $column + * @param array $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereBetweenColumns($column, array $values, $boolean = 'and', $not = false) + { + $type = 'betweenColumns'; + + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); + + return $this; + } + /** * Add an or where between statement to the query. * * @param string $column * @param array $values - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereBetween($column, array $values) { return $this->whereBetween($column, $values, 'or'); } + /** + * Add an or where between statement using columns to the query. + * + * @param string $column + * @param array $values + * @return $this + */ + public function orWhereBetweenColumns($column, array $values) + { + return $this->whereBetweenColumns($column, $values, 'or'); + } + /** * Add a where not between statement to the query. * * @param string $column * @param array $values * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNotBetween($column, array $values, $boolean = 'and') { return $this->whereBetween($column, $values, $boolean, true); } + /** + * Add a where not between statement using columns to the query. + * + * @param string $column + * @param array $values + * @param string $boolean + * @return $this + */ + public function whereNotBetweenColumns($column, array $values, $boolean = 'and') + { + return $this->whereBetweenColumns($column, $values, $boolean, true); + } + /** * Add an or where not between statement to the query. * * @param string $column * @param array $values - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereNotBetween($column, array $values) { return $this->whereNotBetween($column, $values, 'or'); } + /** + * Add an or where not between statement using columns to the query. + * + * @param string $column + * @param array $values + * @return $this + */ + public function orWhereNotBetweenColumns($column, array $values) + { + return $this->whereNotBetweenColumns($column, $values, 'or'); + } + /** * Add an "or where not null" clause to the query. * * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereNotNull($column) { @@ -1099,11 +1267,11 @@ class Builder /** * Add a "where date" statement to the query. * - * @param string $column + * @param \Illuminate\Database\Query\Expression|string $column * @param string $operator * @param \DateTimeInterface|string|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereDate($column, $operator, $value = null, $boolean = 'and') { @@ -1126,7 +1294,7 @@ class Builder * @param string $column * @param string $operator * @param \DateTimeInterface|string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereDate($column, $operator, $value = null) { @@ -1144,7 +1312,7 @@ class Builder * @param string $operator * @param \DateTimeInterface|string|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereTime($column, $operator, $value = null, $boolean = 'and') { @@ -1167,7 +1335,7 @@ class Builder * @param string $column * @param string $operator * @param \DateTimeInterface|string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereTime($column, $operator, $value = null) { @@ -1185,7 +1353,7 @@ class Builder * @param string $operator * @param \DateTimeInterface|string|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereDay($column, $operator, $value = null, $boolean = 'and') { @@ -1212,7 +1380,7 @@ class Builder * @param string $column * @param string $operator * @param \DateTimeInterface|string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereDay($column, $operator, $value = null) { @@ -1230,7 +1398,7 @@ class Builder * @param string $operator * @param \DateTimeInterface|string|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereMonth($column, $operator, $value = null, $boolean = 'and') { @@ -1257,7 +1425,7 @@ class Builder * @param string $column * @param string $operator * @param \DateTimeInterface|string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereMonth($column, $operator, $value = null) { @@ -1275,7 +1443,7 @@ class Builder * @param string $operator * @param \DateTimeInterface|string|int|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereYear($column, $operator, $value = null, $boolean = 'and') { @@ -1298,7 +1466,7 @@ class Builder * @param string $column * @param string $operator * @param \DateTimeInterface|string|int|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereYear($column, $operator, $value = null) { @@ -1335,7 +1503,7 @@ class Builder * * @param \Closure $callback * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNested(Closure $callback, $boolean = 'and') { @@ -1357,7 +1525,7 @@ class Builder /** * Add another query builder as a nested where to the query builder. * - * @param \Illuminate\Database\Query\Builder|static $query + * @param \Illuminate\Database\Query\Builder $query * @param string $boolean * @return $this */ @@ -1426,7 +1594,7 @@ class Builder * * @param \Closure $callback * @param bool $not - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereExists(Closure $callback, $not = false) { @@ -1438,7 +1606,7 @@ class Builder * * @param \Closure $callback * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNotExists(Closure $callback, $boolean = 'and') { @@ -1449,7 +1617,7 @@ class Builder * Add a where not exists clause to the query. * * @param \Closure $callback - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereNotExists(Closure $callback) { @@ -1502,7 +1670,7 @@ class Builder } /** - * Adds a or where condition using row values. + * Adds an or where condition using row values. * * @param array $columns * @param string $operator @@ -1537,7 +1705,7 @@ class Builder } /** - * Add a "or where JSON contains" clause to the query. + * Add an "or where JSON contains" clause to the query. * * @param string $column * @param mixed $value @@ -1562,7 +1730,7 @@ class Builder } /** - * Add a "or where JSON not contains" clause to the query. + * Add an "or where JSON not contains" clause to the query. * * @param string $column * @param mixed $value @@ -1600,7 +1768,7 @@ class Builder } /** - * Add a "or where JSON length" clause to the query. + * Add an "or where JSON length" clause to the query. * * @param string $column * @param mixed $operator @@ -1678,6 +1846,39 @@ class Builder $this->where(Str::snake($segment), '=', $parameters[$index], $bool); } + /** + * Add a "where fulltext" clause to the query. + * + * @param string|string[] $columns + * @param string $value + * @param string $boolean + * @return $this + */ + public function whereFullText($columns, $value, array $options = [], $boolean = 'and') + { + $type = 'Fulltext'; + + $columns = (array) $columns; + + $this->wheres[] = compact('type', 'columns', 'value', 'options', 'boolean'); + + $this->addBinding($value); + + return $this; + } + + /** + * Add a "or where fulltext" clause to the query. + * + * @param string|string[] $columns + * @param string $value + * @return $this + */ + public function orWhereFullText($columns, $value, array $options = []) + { + return $this->whereFulltext($columns, $value, $options, 'or'); + } + /** * Add a "group by" clause to the query. * @@ -1739,6 +1940,10 @@ class Builder [$value, $operator] = [$operator, '=']; } + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; + } + $this->havings[] = compact('type', 'column', 'operator', 'value', 'boolean'); if (! $value instanceof Expression) { @@ -1749,12 +1954,12 @@ class Builder } /** - * Add a "or having" clause to the query. + * Add an "or having" clause to the query. * * @param string $column * @param string|null $operator * @param string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orHaving($column, $operator = null, $value = null) { @@ -1772,7 +1977,7 @@ class Builder * @param array $values * @param string $boolean * @param bool $not - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function havingBetween($column, array $values, $boolean = 'and', $not = false) { @@ -1809,7 +2014,7 @@ class Builder * * @param string $sql * @param array $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orHavingRaw($sql, array $bindings = []) { @@ -1819,7 +2024,7 @@ class Builder /** * Add an "order by" clause to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column + * @param \Closure|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column * @param string $direction * @return $this * @@ -1852,7 +2057,7 @@ class Builder /** * Add a descending "order by" clause to the query. * - * @param string $column + * @param \Closure|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column * @return $this */ public function orderByDesc($column) @@ -1863,8 +2068,8 @@ class Builder /** * Add an "order by" clause for a timestamp to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column + * @return $this */ public function latest($column = 'created_at') { @@ -1874,8 +2079,8 @@ class Builder /** * Add an "order by" clause for a timestamp to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column + * @return $this */ public function oldest($column = 'created_at') { @@ -1915,7 +2120,7 @@ class Builder * Alias to set the "offset" value of the query. * * @param int $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function skip($value) { @@ -1932,7 +2137,7 @@ class Builder { $property = $this->unions ? 'unionOffset' : 'offset'; - $this->$property = max(0, $value); + $this->$property = max(0, (int) $value); return $this; } @@ -1941,7 +2146,7 @@ class Builder * Alias to set the "limit" value of the query. * * @param int $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function take($value) { @@ -1959,7 +2164,7 @@ class Builder $property = $this->unions ? 'unionLimit' : 'limit'; if ($value >= 0) { - $this->$property = $value; + $this->$property = ! is_null($value) ? (int) $value : null; } return $this; @@ -1970,7 +2175,7 @@ class Builder * * @param int $page * @param int $perPage - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function forPage($page, $perPage = 15) { @@ -1983,7 +2188,7 @@ class Builder * @param int $perPage * @param int|null $lastId * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function forPageBeforeId($perPage = 15, $lastId = 0, $column = 'id') { @@ -2003,7 +2208,7 @@ class Builder * @param int $perPage * @param int|null $lastId * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id') { @@ -2017,6 +2222,27 @@ class Builder ->limit($perPage); } + /** + * Remove all existing orders and optionally add a new order. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string|null $column + * @param string $direction + * @return $this + */ + public function reorder($column = null, $direction = 'asc') + { + $this->orders = null; + $this->unionOrders = null; + $this->bindings['order'] = []; + $this->bindings['unionOrder'] = []; + + if ($column) { + return $this->orderBy($column, $direction); + } + + return $this; + } + /** * Get an array with all orders with a given column removed. * @@ -2037,7 +2263,7 @@ class Builder * * @param \Illuminate\Database\Query\Builder|\Closure $query * @param bool $all - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function union($query, $all = false) { @@ -2056,7 +2282,7 @@ class Builder * Add a union all statement to the query. * * @param \Illuminate\Database\Query\Builder|\Closure $query - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function unionAll($query) { @@ -2100,6 +2326,33 @@ class Builder return $this->lock(false); } + /** + * Register a closure to be invoked before the query is executed. + * + * @param callable $callback + * @return $this + */ + public function beforeQuery(callable $callback) + { + $this->beforeQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "before query" modification callbacks. + * + * @return void + */ + public function applyBeforeQueryCallbacks() + { + foreach ($this->beforeQueryCallbacks as $callback) { + $callback($this); + } + + $this->beforeQueryCallbacks = []; + } + /** * Get the SQL representation of the query. * @@ -2107,6 +2360,8 @@ class Builder */ public function toSql() { + $this->applyBeforeQueryCallbacks(); + return $this->grammar->compileSelect($this); } @@ -2206,6 +2461,43 @@ class Builder ]); } + /** + * Get a paginator only supporting simple next and previous links. + * + * This is more efficient on larger data-sets, etc. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param \Illuminate\Pagination\Cursor|string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + } + + /** + * Ensure the proper order by required for cursor pagination. + * + * @param bool $shouldReverse + * @return \Illuminate\Support\Collection + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + $this->enforceOrderBy(); + + return collect($this->orders ?? $this->unionOrders ?? [])->filter(function ($order) { + return Arr::has($order, 'direction'); + })->when($shouldReverse, function (Collection $orders) { + return $orders->map(function ($order) { + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + }); + })->values(); + } + /** * Get the count of the total records for the paginator. * @@ -2219,9 +2511,7 @@ class Builder // Once we have run the pagination count query, we will get the resulting count and // take into account what type of query it was. When there is a group by we will // just return the count of the entire results set since that will be correct. - if (isset($this->groups)) { - return count($results); - } elseif (! isset($results[0])) { + if (! isset($results[0])) { return 0; } elseif (is_object($results[0])) { return (int) $results[0]->aggregate; @@ -2238,6 +2528,20 @@ class Builder */ protected function runPaginationCountQuery($columns = ['*']) { + if ($this->groups || $this->havings) { + $clone = $this->cloneForPaginationCount(); + + if (is_null($clone->columns) && ! empty($this->joins)) { + $clone->select($this->from.'.*'); + } + + return $this->newQuery() + ->from(new Expression('('.$clone->toSql().') as '.$this->grammar->wrap('aggregate_table'))) + ->mergeBindings($clone) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } + $without = $this->unions ? ['orders', 'limit', 'offset'] : ['columns', 'orders', 'limit', 'offset']; return $this->cloneWithout($without) @@ -2246,6 +2550,17 @@ class Builder ->get()->all(); } + /** + * Clone the existing query instance for usage in a pagination subquery. + * + * @return self + */ + protected function cloneForPaginationCount() + { + return $this->cloneWithout(['orders', 'limit', 'offset']) + ->cloneWithoutBindings(['order']); + } + /** * Remove the column aliases since they will break count queries. * @@ -2293,7 +2608,7 @@ class Builder } /** - * Get an array with the values of a given column. + * Get a collection instance containing the values of a given column. * * @param string $column * @param string|null $key @@ -2415,6 +2730,8 @@ class Builder */ public function exists() { + $this->applyBeforeQueryCallbacks(); + $results = $this->connection->select( $this->grammar->compileExists($this), $this->getBindings(), ! $this->useWritePdo ); @@ -2540,8 +2857,8 @@ class Builder */ public function aggregate($function, $columns = ['*']) { - $results = $this->cloneWithout($this->unions ? [] : ['columns']) - ->cloneWithoutBindings($this->unions ? [] : ['select']) + $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) ->setAggregate($function, $columns) ->get($columns); @@ -2624,7 +2941,7 @@ class Builder } /** - * Insert a new record into the database. + * Insert new records into the database. * * @param array $values * @return bool @@ -2653,6 +2970,8 @@ class Builder } } + $this->applyBeforeQueryCallbacks(); + // Finally, we will run this query against the database connection and return // the results. We will need to also flatten these bindings before running // the query so they are all in one huge, flattened array for execution. @@ -2663,7 +2982,7 @@ class Builder } /** - * Insert a new record into the database while ignoring errors. + * Insert new records into the database while ignoring errors. * * @param array $values * @return int @@ -2683,6 +3002,8 @@ class Builder } } + $this->applyBeforeQueryCallbacks(); + return $this->connection->affectingStatement( $this->grammar->compileInsertOrIgnore($this, $values), $this->cleanBindings(Arr::flatten($values, 1)) @@ -2698,6 +3019,8 @@ class Builder */ public function insertGetId(array $values, $sequence = null) { + $this->applyBeforeQueryCallbacks(); + $sql = $this->grammar->compileInsertGetId($this, $values, $sequence); $values = $this->cleanBindings($values); @@ -2714,6 +3037,8 @@ class Builder */ public function insertUsing(array $columns, $query) { + $this->applyBeforeQueryCallbacks(); + [$sql, $bindings] = $this->createSub($query); return $this->connection->affectingStatement( @@ -2723,13 +3048,15 @@ class Builder } /** - * Update a record in the database. + * Update records in the database. * * @param array $values * @return int */ public function update(array $values) { + $this->applyBeforeQueryCallbacks(); + $sql = $this->grammar->compileUpdate($this, $values); return $this->connection->update($sql, $this->cleanBindings( @@ -2737,6 +3064,27 @@ class Builder )); } + /** + * Update records in a PostgreSQL database using the update from syntax. + * + * @param array $values + * @return int + */ + public function updateFrom(array $values) + { + if (! method_exists($this->grammar, 'compileUpdateFrom')) { + throw new LogicException('This database engine does not support the updateFrom method.'); + } + + $this->applyBeforeQueryCallbacks(); + + $sql = $this->grammar->compileUpdateFrom($this, $values); + + return $this->connection->update($sql, $this->cleanBindings( + $this->grammar->prepareBindingsForUpdateFrom($this->bindings, $values) + )); + } + /** * Insert or update a record matching the attributes, and fill it with values. * @@ -2757,6 +3105,51 @@ class Builder return (bool) $this->limit(1)->update($values); } + /** + * Insert new records or update the existing ones. + * + * @param array $values + * @param array|string $uniqueBy + * @param array|null $update + * @return int + */ + public function upsert(array $values, $uniqueBy, $update = null) + { + if (empty($values)) { + return 0; + } elseif ($update === []) { + return (int) $this->insert($values); + } + + if (! is_array(reset($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + if (is_null($update)) { + $update = array_keys(reset($values)); + } + + $this->applyBeforeQueryCallbacks(); + + $bindings = $this->cleanBindings(array_merge( + Arr::flatten($values, 1), + collect($update)->reject(function ($value, $key) { + return is_int($key); + })->all() + )); + + return $this->connection->affectingStatement( + $this->grammar->compileUpsert($this, $values, (array) $uniqueBy, $update), + $bindings + ); + } + /** * Increment a column's value by a given amount. * @@ -2804,7 +3197,7 @@ class Builder } /** - * Delete a record from the database. + * Delete records from the database. * * @param mixed $id * @return int @@ -2818,6 +3211,8 @@ class Builder $this->where($this->from.'.id', '=', $id); } + $this->applyBeforeQueryCallbacks(); + return $this->connection->delete( $this->grammar->compileDelete($this), $this->cleanBindings( $this->grammar->prepareBindingsForDelete($this->bindings) @@ -2832,6 +3227,8 @@ class Builder */ public function truncate() { + $this->applyBeforeQueryCallbacks(); + foreach ($this->grammar->compileTruncate($this) as $sql => $bindings) { $this->connection->statement($sql, $bindings); } @@ -2924,14 +3321,32 @@ class Builder } if (is_array($value)) { - $this->bindings[$type] = array_values(array_merge($this->bindings[$type], $value)); + $this->bindings[$type] = array_values(array_map( + [$this, 'castBinding'], + array_merge($this->bindings[$type], $value), + )); } else { - $this->bindings[$type][] = $value; + $this->bindings[$type][] = $this->castBinding($value); } return $this; } + /** + * Cast the given binding value. + * + * @param mixed $value + * @return mixed + */ + public function castBinding($value) + { + if (function_exists('enum_exists') && $value instanceof BackedEnum) { + return $value->value; + } + + return $value; + } + /** * Merge an array of bindings into our bindings. * @@ -2951,11 +3366,15 @@ class Builder * @param array $bindings * @return array */ - protected function cleanBindings(array $bindings) + public function cleanBindings(array $bindings) { - return array_values(array_filter($bindings, function ($binding) { - return ! $binding instanceof Expression; - })); + return collect($bindings) + ->reject(function ($binding) { + return $binding instanceof Expression; + }) + ->map([$this, 'castBinding']) + ->values() + ->all(); } /** @@ -3031,9 +3450,20 @@ class Builder { return $value instanceof self || $value instanceof EloquentBuilder || + $value instanceof Relation || $value instanceof Closure; } + /** + * Clone the query. + * + * @return static + */ + public function clone() + { + return clone $this; + } + /** * Clone the query without the given properties. * @@ -3042,7 +3472,7 @@ class Builder */ public function cloneWithout(array $properties) { - return tap(clone $this, function ($clone) use ($properties) { + return tap($this->clone(), function ($clone) use ($properties) { foreach ($properties as $property) { $clone->{$property} = null; } @@ -3057,7 +3487,7 @@ class Builder */ public function cloneWithoutBindings(array $except) { - return tap(clone $this, function ($clone) use ($except) { + return tap($this->clone(), function ($clone) use ($except) { foreach ($except as $type) { $clone->bindings[$type] = []; } @@ -3079,7 +3509,7 @@ class Builder /** * Die and dump the current SQL and bindings. * - * @return void + * @return never */ public function dd() { diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index a7b930e16e223e884532beafea20ed177dfbf44b..0dbdb1e0535ec0509ccc8e4dfaf89c203a05f416 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -19,10 +19,17 @@ class Grammar extends BaseGrammar protected $operators = []; /** - * The components that make up a select clause. + * The grammar specific bitwise operators. * * @var array */ + protected $bitwiseOperators = []; + + /** + * The components that make up a select clause. + * + * @var string[] + */ protected $selectComponents = [ 'aggregate', 'columns', @@ -45,7 +52,7 @@ class Grammar extends BaseGrammar */ public function compileSelect(Builder $query) { - if ($query->unions && $query->aggregate) { + if (($query->unions || $query->havings) && $query->aggregate) { return $this->compileUnionAggregate($query); } @@ -85,10 +92,7 @@ class Grammar extends BaseGrammar $sql = []; foreach ($this->selectComponents as $component) { - // To compile the query, we'll spin through each component of the query and - // see if that component exists. If it does we'll just call the compiler - // function for the component which is responsible for making the SQL. - if (isset($query->$component) && ! is_null($query->$component)) { + if (isset($query->$component)) { $method = 'compile'.ucfirst($component); $sql[$component] = $this->$method($query, $query->$component); @@ -184,7 +188,7 @@ class Grammar extends BaseGrammar * @param \Illuminate\Database\Query\Builder $query * @return string */ - protected function compileWheres(Builder $query) + public function compileWheres(Builder $query) { // Each type of where clauses has its own compiler function which is responsible // for actually creating the where clauses SQL. This helps keep the code nice @@ -253,7 +257,21 @@ class Grammar extends BaseGrammar { $value = $this->parameter($where['value']); - return $this->wrap($where['column']).' '.$where['operator'].' '.$value; + $operator = str_replace('?', '??', $where['operator']); + + return $this->wrap($where['column']).' '.$operator.' '.$value; + } + + /** + * Compile a bitwise operator where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBitwise(Builder $query, $where) + { + return $this->whereBasic($query, $where); } /** @@ -366,6 +384,24 @@ class Grammar extends BaseGrammar return $this->wrap($where['column']).' '.$between.' '.$min.' and '.$max; } + /** + * Compile a "between" where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBetweenColumns(Builder $query, $where) + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->wrap(reset($where['values'])); + + $max = $this->wrap(end($where['values'])); + + return $this->wrap($where['column']).' '.$between.' '.$min.' and '.$max; + } + /** * Compile a "where date" clause. * @@ -442,7 +478,7 @@ class Grammar extends BaseGrammar } /** - * Compile a where clause comparing two columns.. + * Compile a where clause comparing two columns. * * @param \Illuminate\Database\Query\Builder $query * @param array $where @@ -554,7 +590,8 @@ class Grammar extends BaseGrammar $not = $where['not'] ? 'not ' : ''; return $not.$this->compileJsonContains( - $where['column'], $this->parameter($where['value']) + $where['column'], + $this->parameter($where['value']) ); } @@ -593,7 +630,9 @@ class Grammar extends BaseGrammar protected function whereJsonLength(Builder $query, $where) { return $this->compileJsonLength( - $where['column'], $where['operator'], $this->parameter($where['value']) + $where['column'], + $where['operator'], + $this->parameter($where['value']) ); } @@ -612,6 +651,18 @@ class Grammar extends BaseGrammar throw new RuntimeException('This database engine does not support JSON length operations.'); } + /** + * Compile a "where fulltext" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + public function whereFullText(Builder $query, $where) + { + throw new RuntimeException('This database engine does not support fulltext search operations.'); + } + /** * Compile the "group by" portions of the query. * @@ -978,6 +1029,22 @@ class Grammar extends BaseGrammar return "update {$table} {$joins} set {$columns} {$where}"; } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + * + * @throws \RuntimeException + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + throw new RuntimeException('This database engine does not support upserts.'); + } + /** * Prepare the bindings for an update statement. * @@ -1251,4 +1318,14 @@ class Grammar extends BaseGrammar { return $this->operators; } + + /** + * Get the grammar specific bitwise operators. + * + * @return array + */ + public function getBitwiseOperators() + { + return $this->bitwiseOperators; + } } diff --git a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php index c26ad195b5ace1bd47283488d8e475728c65e696..404b3d5408b007c367fd6ca440f484e6e2e3c77a 100755 --- a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php @@ -10,10 +10,70 @@ class MySqlGrammar extends Grammar /** * The grammar specific operators. * - * @var array + * @var string[] */ protected $operators = ['sounds like']; + /** + * Add a "where null" clause to the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereNull(Builder $query, $where) + { + if ($this->isJsonSelector($where['column'])) { + [$field, $path] = $this->wrapJsonFieldAndPath($where['column']); + + return '(json_extract('.$field.$path.') is null OR json_type(json_extract('.$field.$path.')) = \'NULL\')'; + } + + return parent::whereNull($query, $where); + } + + /** + * Add a "where not null" clause to the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereNotNull(Builder $query, $where) + { + if ($this->isJsonSelector($where['column'])) { + [$field, $path] = $this->wrapJsonFieldAndPath($where['column']); + + return '(json_extract('.$field.$path.') is not null AND json_type(json_extract('.$field.$path.')) != \'NULL\')'; + } + + return parent::whereNotNull($query, $where); + } + + /** + * Compile a "where fulltext" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + public function whereFullText(Builder $query, $where) + { + $columns = $this->columnize($where['columns']); + + $value = $this->parameter($where['value']); + + $mode = ($where['options']['mode'] ?? []) === 'boolean' + ? ' in boolean mode' + : ' in natural language mode'; + + $expanded = ($where['options']['expanded'] ?? []) && ($where['options']['mode'] ?? []) !== 'boolean' + ? ' with query expansion' + : ''; + + return "match ({$columns}) against (".$value."{$mode}{$expanded})"; + } + /** * Compile an insert ignore statement into SQL. * @@ -116,6 +176,28 @@ class MySqlGrammar extends Grammar })->implode(', '); } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $sql = $this->compileInsert($query, $values).' on duplicate key update '; + + $columns = collect($update)->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = values('.$this->wrap($value).')' + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); + + return $sql.$columns; + } + /** * Prepare a JSON column being updated using the JSON_SET function. * diff --git a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php index 46420bb6a596b1580d74ed0673f8d93a56fd2b6a..1b49bf10e9cb409e13f0915a1b0b1564568d917e 100755 --- a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php @@ -11,16 +11,25 @@ class PostgresGrammar extends Grammar /** * All of the available clause operators. * - * @var array + * @var string[] */ protected $operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', 'like', 'not like', 'between', 'ilike', 'not ilike', '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', - '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '-', '#-', + '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '@?', '@@', '#-', 'is distinct from', 'is not distinct from', ]; + /** + * The grammar specific bitwise operators. + * + * @var array + */ + protected $bitwiseOperators = [ + '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', + ]; + /** * {@inheritdoc} * @@ -42,6 +51,22 @@ class PostgresGrammar extends Grammar return parent::whereBasic($query, $where); } + /** + * {@inheritdoc} + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBitwise(Builder $query, $where) + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return '('.$this->wrap($where['column']).' '.$operator.' '.$value.')::bool'; + } + /** * Compile a "where date" clause. * @@ -85,6 +110,71 @@ class PostgresGrammar extends Grammar return 'extract('.$type.' from '.$this->wrap($where['column']).') '.$where['operator'].' '.$value; } + /** + * Compile a "where fulltext" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + public function whereFullText(Builder $query, $where) + { + $language = $where['options']['language'] ?? 'english'; + + if (! in_array($language, $this->validFullTextLanguages())) { + $language = 'english'; + } + + $columns = collect($where['columns'])->map(function ($column) use ($language) { + return "to_tsvector('{$language}', {$this->wrap($column)})"; + })->implode(' || '); + + $mode = 'plainto_tsquery'; + + if (($where['options']['mode'] ?? []) === 'phrase') { + $mode = 'phraseto_tsquery'; + } + + if (($where['options']['mode'] ?? []) === 'websearch') { + $mode = 'websearch_to_tsquery'; + } + + return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})"; + } + + /** + * Get an array of valid full text languages. + * + * @return array + */ + protected function validFullTextLanguages() + { + return [ + 'simple', + 'arabic', + 'danish', + 'dutch', + 'english', + 'finnish', + 'french', + 'german', + 'hungarian', + 'indonesian', + 'irish', + 'italian', + 'lithuanian', + 'nepali', + 'norwegian', + 'portuguese', + 'romanian', + 'russian', + 'spanish', + 'swedish', + 'tamil', + 'turkish', + ]; + } + /** * Compile the "select *" portion of the query. * @@ -141,6 +231,36 @@ class PostgresGrammar extends Grammar return 'json_array_length(('.$column.')::json) '.$operator.' '.$value; } + /** + * {@inheritdoc} + * + * @param array $having + * @return string + */ + protected function compileHaving(array $having) + { + if ($having['type'] === 'Bitwise') { + return $this->compileHavingBitwise($having); + } + + return parent::compileHaving($having); + } + + /** + * Compile a having clause involving a bitwise operator. + * + * @param array $having + * @return string + */ + protected function compileHavingBitwise($having) + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return $having['boolean'].' ('.$column.' '.$having['operator'].' '.$parameter.')::bool'; + } + /** * Compile the lock into SQL. * @@ -218,6 +338,30 @@ class PostgresGrammar extends Grammar })->implode(', '); } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; + + $columns = collect($update)->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrapValue('excluded').'.'.$this->wrap($value) + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); + + return $sql.$columns; + } + /** * Prepares a JSON column being updated using the JSONB_SET function. * @@ -236,6 +380,114 @@ class PostgresGrammar extends Grammar return "{$field} = jsonb_set({$field}::jsonb, {$path}, {$this->parameter($value)})"; } + /** + * Compile an update from statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + public function compileUpdateFrom(Builder $query, $values) + { + $table = $this->wrapTable($query->from); + + // Each one of the columns in the update statements needs to be wrapped in the + // keyword identifiers, also a place-holder needs to be created for each of + // the values in the list of bindings so we can make the sets statements. + $columns = $this->compileUpdateColumns($query, $values); + + $from = ''; + + if (isset($query->joins)) { + // When using Postgres, updates with joins list the joined tables in the from + // clause, which is different than other systems like MySQL. Here, we will + // compile out the tables that are joined and add them to a from clause. + $froms = collect($query->joins)->map(function ($join) { + return $this->wrapTable($join->table); + })->all(); + + if (count($froms) > 0) { + $from = ' from '.implode(', ', $froms); + } + } + + $where = $this->compileUpdateWheres($query); + + return trim("update {$table} set {$columns}{$from} {$where}"); + } + + /** + * Compile the additional where clauses for updates with joins. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileUpdateWheres(Builder $query) + { + $baseWheres = $this->compileWheres($query); + + if (! isset($query->joins)) { + return $baseWheres; + } + + // Once we compile the join constraints, we will either use them as the where + // clause or append them to the existing base where clauses. If we need to + // strip the leading boolean we will do so when using as the only where. + $joinWheres = $this->compileUpdateJoinWheres($query); + + if (trim($baseWheres) == '') { + return 'where '.$this->removeLeadingBoolean($joinWheres); + } + + return $baseWheres.' '.$joinWheres; + } + + /** + * Compile the "join" clause where clauses for an update. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileUpdateJoinWheres(Builder $query) + { + $joinWheres = []; + + // Here we will just loop through all of the join constraints and compile them + // all out then implode them. This should give us "where" like syntax after + // everything has been built and then we will join it to the real wheres. + foreach ($query->joins as $join) { + foreach ($join->wheres as $where) { + $method = "where{$where['type']}"; + + $joinWheres[] = $where['boolean'].' '.$this->$method($query, $where); + } + } + + return implode(' ', $joinWheres); + } + + /** + * Prepare the bindings for an update statement. + * + * @param array $bindings + * @param array $values + * @return array + */ + public function prepareBindingsForUpdateFrom(array $bindings, array $values) + { + $values = collect($values)->map(function ($value, $column) { + return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) + ? json_encode($value) + : $value; + })->all(); + + $bindingsWithoutWhere = Arr::except($bindings, ['select', 'where']); + + return array_values( + array_merge($values, $bindings['where'], Arr::flatten($bindingsWithoutWhere)) + ); + } + /** * Compile an update statement with joins or limit into SQL. * @@ -345,7 +597,7 @@ class PostgresGrammar extends Grammar } /** - *Wrap the given JSON selector for boolean values. + * Wrap the given JSON selector for boolean values. * * @param string $value * @return string diff --git a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php index 2c27ddf3c0e6980c44062c3f347b8681d6a983ba..29a3796860e7cb004a1b6511b398f57a4a478e39 100755 --- a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php @@ -11,7 +11,7 @@ class SQLiteGrammar extends Grammar /** * All of the available clause operators. * - * @var array + * @var string[] */ protected $operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', @@ -182,6 +182,30 @@ class SQLiteGrammar extends Grammar })->implode(', '); } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; + + $columns = collect($update)->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrapValue('excluded').'.'.$this->wrap($value) + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); + + return $sql.$columns; + } + /** * Group the nested JSON columns. * diff --git a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php index f0a0bfc5190bf6d2b05c78b9164c401968cff6e7..417b63e4a324a1e24865b4870f2abeb7ddd76217 100755 --- a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php @@ -4,13 +4,14 @@ namespace Illuminate\Database\Query\Grammars; use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; +use Illuminate\Support\Str; class SqlServerGrammar extends Grammar { /** * All of the available clause operators. * - * @var array + * @var string[] */ protected $operators = [ '=', '<', '>', '<=', '>=', '!<', '!>', '<>', '!=', @@ -30,15 +31,21 @@ class SqlServerGrammar extends Grammar return parent::compileSelect($query); } - // If an offset is present on the query, we will need to wrap the query in - // a big "ANSI" offset syntax block. This is very nasty compared to the - // other database systems but is necessary for implementing features. if (is_null($query->columns)) { $query->columns = ['*']; } + $components = $this->compileComponents($query); + + if (! empty($components['orders'])) { + return parent::compileSelect($query)." offset {$query->offset} rows fetch next {$query->limit} rows only"; + } + + // If an offset is present on the query, we will need to wrap the query in + // a big "ANSI" offset syntax block. This is very nasty compared to the + // other database systems but is necessary for implementing features. return $this->compileAnsiOffset( - $query, $this->compileComponents($query) + $query, $components ); } @@ -60,8 +67,8 @@ class SqlServerGrammar extends Grammar // If there is a limit on the query, but not an offset, we will add the top // clause to the query, which serves as a "limit" type clause within the // SQL Server system similar to the limit keywords available in MySQL. - if ($query->limit > 0 && $query->offset <= 0) { - $select .= 'top '.$query->limit.' '; + if (is_numeric($query->limit) && $query->limit > 0 && $query->offset <= 0) { + $select .= 'top '.((int) $query->limit).' '; } return $select.$this->columnize($columns); @@ -89,6 +96,22 @@ class SqlServerGrammar extends Grammar return $from; } + /** + * {@inheritdoc} + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBitwise(Builder $query, $where) + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return '('.$this->wrap($where['column']).' '.$operator.' '.$value.') != 0'; + } + /** * Compile a "where date" clause. * @@ -157,6 +180,36 @@ class SqlServerGrammar extends Grammar return '(select count(*) from openjson('.$field.$path.')) '.$operator.' '.$value; } + /** + * {@inheritdoc} + * + * @param array $having + * @return string + */ + protected function compileHaving(array $having) + { + if ($having['type'] === 'Bitwise') { + return $this->compileHavingBitwise($having); + } + + return parent::compileHaving($having); + } + + /** + * Compile a having clause involving a bitwise operator. + * + * @param array $having + * @return string + */ + protected function compileHavingBitwise($having) + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return $having['boolean'].' ('.$column.' '.$having['operator'].' '.$parameter.') != 0'; + } + /** * Create a full ANSI offset clause for the query. * @@ -180,6 +233,10 @@ class SqlServerGrammar extends Grammar unset($components['orders']); + if ($this->queryOrderContainsSubquery($query)) { + $query->bindings = $this->sortBindingsForSubqueryOrderBy($query); + } + // Next we need to calculate the constraints that should be placed on the query // to get the right offset and limit from our query but if there is no limit // set we will just handle the offset only since that is all that matters. @@ -199,6 +256,36 @@ class SqlServerGrammar extends Grammar return ", row_number() over ({$orderings}) as row_num"; } + /** + * Determine if the query's order by clauses contain a subquery. + * + * @param \Illuminate\Database\Query\Builder $query + * @return bool + */ + protected function queryOrderContainsSubquery($query) + { + if (! is_array($query->orders)) { + return false; + } + + return Arr::first($query->orders, function ($value) { + return $this->isExpression($value['column'] ?? null); + }, false) !== false; + } + + /** + * Move the order bindings to be after the "select" statement to account for an order by subquery. + * + * @param \Illuminate\Database\Query\Builder $query + * @return array + */ + protected function sortBindingsForSubqueryOrderBy($query) + { + return Arr::sort($query->bindings, function ($bindings, $key) { + return array_search($key, ['select', 'order', 'from', 'join', 'where', 'groupBy', 'having', 'union', 'unionOrder']); + }); + } + /** * Compile a common table expression for a query. * @@ -221,10 +308,10 @@ class SqlServerGrammar extends Grammar */ protected function compileRowConstraint($query) { - $start = $query->offset + 1; + $start = (int) $query->offset + 1; if ($query->limit > 0) { - $finish = $query->offset + $query->limit; + $finish = (int) $query->offset + (int) $query->limit; return "between {$start} and {$finish}"; } @@ -232,6 +319,23 @@ class SqlServerGrammar extends Grammar return ">= {$start}"; } + /** + * Compile a delete statement without joins into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $where + * @return string + */ + protected function compileDeleteWithoutJoins(Builder $query, $table, $where) + { + $sql = parent::compileDeleteWithoutJoins($query, $table, $where); + + return ! is_null($query->limit) && $query->limit > 0 && $query->offset <= 0 + ? Str::replaceFirst('delete', 'delete top ('.$query->limit.')', $sql) + : $sql; + } + /** * Compile the random statement into SQL. * @@ -323,6 +427,48 @@ class SqlServerGrammar extends Grammar return "update {$alias} set {$columns} from {$table} {$joins} {$where}"; } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $columns = $this->columnize(array_keys(reset($values))); + + $sql = 'merge '.$this->wrapTable($query->from).' '; + + $parameters = collect($values)->map(function ($record) { + return '('.$this->parameterize($record).')'; + })->implode(', '); + + $sql .= 'using (values '.$parameters.') '.$this->wrapTable('laravel_source').' ('.$columns.') '; + + $on = collect($uniqueBy)->map(function ($column) use ($query) { + return $this->wrap('laravel_source.'.$column).' = '.$this->wrap($query->from.'.'.$column); + })->implode(' and '); + + $sql .= 'on '.$on.' '; + + if ($update) { + $update = collect($update)->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrap('laravel_source.'.$value) + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); + + $sql .= 'when matched then update set '.$update.' '; + } + + $sql .= 'when not matched then insert ('.$columns.') values ('.$columns.');'; + + return $sql; + } + /** * Prepare the bindings for an update statement. * diff --git a/src/Illuminate/Database/Query/JoinClause.php b/src/Illuminate/Database/Query/JoinClause.php index 800da42ef3fb8d858a72528e3dcbd5a71decad40..57d650a38cb11f45fc10f86fde4223c4058a9c75 100755 --- a/src/Illuminate/Database/Query/JoinClause.php +++ b/src/Illuminate/Database/Query/JoinClause.php @@ -104,7 +104,7 @@ class JoinClause extends Builder * * @param \Closure|string $first * @param string|null $operator - * @param string|null $second + * @param \Illuminate\Database\Query\Expression|string|null $second * @return \Illuminate\Database\Query\JoinClause */ public function orOn($first, $operator = null, $second = null) diff --git a/src/Illuminate/Database/QueryException.php b/src/Illuminate/Database/QueryException.php index 8e64160d65971aaf6aae9eaf5e09fc9878fa06c6..74e5a31aaacaaf77b54fff1b1f908f48294c14a0 100644 --- a/src/Illuminate/Database/QueryException.php +++ b/src/Illuminate/Database/QueryException.php @@ -4,6 +4,7 @@ namespace Illuminate\Database; use Illuminate\Support\Str; use PDOException; +use Throwable; class QueryException extends PDOException { @@ -26,10 +27,10 @@ class QueryException extends PDOException * * @param string $sql * @param array $bindings - * @param \Exception $previous + * @param \Throwable $previous * @return void */ - public function __construct($sql, array $bindings, $previous) + public function __construct($sql, array $bindings, Throwable $previous) { parent::__construct('', 0, $previous); @@ -48,10 +49,10 @@ class QueryException extends PDOException * * @param string $sql * @param array $bindings - * @param \Exception $previous + * @param \Throwable $previous * @return string */ - protected function formatMessage($sql, $bindings, $previous) + protected function formatMessage($sql, $bindings, Throwable $previous) { return $previous->getMessage().' (SQL: '.Str::replaceArray('?', $bindings, $sql).')'; } diff --git a/src/Illuminate/Database/README.md b/src/Illuminate/Database/README.md index 7d59ab7d8c6615a23c3690fddba43e2858b1bbde..9019936025b6ac2917fa95e76b820ef3320d403f 100755 --- a/src/Illuminate/Database/README.md +++ b/src/Illuminate/Database/README.md @@ -12,14 +12,14 @@ use Illuminate\Database\Capsule\Manager as Capsule; $capsule = new Capsule; $capsule->addConnection([ - 'driver' => 'mysql', - 'host' => 'localhost', - 'database' => 'database', - 'username' => 'root', - 'password' => 'password', - 'charset' => 'utf8', + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'database', + 'username' => 'root', + 'password' => 'password', + 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', - 'prefix' => '', + 'prefix' => '', ]); // Set the event dispatcher used by Eloquent models... (optional) diff --git a/src/Illuminate/Database/RecordsNotFoundException.php b/src/Illuminate/Database/RecordsNotFoundException.php new file mode 100755 index 0000000000000000000000000000000000000000..3e0d9557581da4a3cdc86bc28def42f57f9606ea --- /dev/null +++ b/src/Illuminate/Database/RecordsNotFoundException.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Database; + +use RuntimeException; + +class RecordsNotFoundException extends RuntimeException +{ + // +} diff --git a/src/Illuminate/Database/SQLiteConnection.php b/src/Illuminate/Database/SQLiteConnection.php index 4990fdd299a1d893fb08f9951d7a0b7cc79553ee..38116877c3caca0e27b0ead28e77a1f802a69f6f 100755 --- a/src/Illuminate/Database/SQLiteConnection.php +++ b/src/Illuminate/Database/SQLiteConnection.php @@ -3,10 +3,14 @@ namespace Illuminate\Database; use Doctrine\DBAL\Driver\PDOSqlite\Driver as DoctrineDriver; +use Doctrine\DBAL\Version; +use Illuminate\Database\PDO\SQLiteDriver; use Illuminate\Database\Query\Grammars\SQLiteGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SQLiteProcessor; use Illuminate\Database\Schema\Grammars\SQLiteGrammar as SchemaGrammar; use Illuminate\Database\Schema\SQLiteBuilder; +use Illuminate\Database\Schema\SqliteSchemaState; +use Illuminate\Filesystem\Filesystem; class SQLiteConnection extends Connection { @@ -68,6 +72,19 @@ class SQLiteConnection extends Connection return $this->withTablePrefix(new SchemaGrammar); } + /** + * Get the schema state for the connection. + * + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * + * @throws \RuntimeException + */ + public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + { + return new SqliteSchemaState($this, $files, $processFactory); + } + /** * Get the default post processor instance. * @@ -81,11 +98,11 @@ class SQLiteConnection extends Connection /** * Get the Doctrine DBAL driver. * - * @return \Doctrine\DBAL\Driver\PDOSqlite\Driver + * @return \Doctrine\DBAL\Driver\PDOSqlite\Driver|\Illuminate\Database\PDO\SQLiteDriver */ protected function getDoctrineDriver() { - return new DoctrineDriver; + return class_exists(Version::class) ? new DoctrineDriver : new SQLiteDriver; } /** diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index b475f5f2c6bd1c328d999dc951091568e54b1d6f..dfe53ee792a838ffa769190e08157224c055a005 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -5,6 +5,7 @@ namespace Illuminate\Database\Schema; use BadMethodCallException; use Closure; use Illuminate\Database\Connection; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Grammars\Grammar; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Fluent; @@ -51,11 +52,15 @@ class Blueprint /** * The default character set that should be used for the table. + * + * @var string */ public $charset; /** * The collation that should be used for the table. + * + * @var string */ public $collation; @@ -66,6 +71,13 @@ class Blueprint */ public $temporary = false; + /** + * The column to add new columns after. + * + * @var string + */ + public $after; + /** * Create a new schema blueprint. * @@ -196,7 +208,7 @@ class Blueprint protected function addFluentIndexes() { foreach ($this->columns as $column) { - foreach (['primary', 'unique', 'index', 'spatialIndex'] as $index) { + foreach (['primary', 'unique', 'index', 'fulltext', 'fullText', 'spatialIndex'] as $index) { // If the index has been specified on the given column, but is simply equal // to "true" (boolean), no name has been specified for this index so the // index method can be called without a name and it will generate one. @@ -250,7 +262,7 @@ class Blueprint * * @return bool */ - protected function creating() + public function creating() { return collect($this->commands)->contains(function ($command) { return $command->name === 'create'; @@ -355,6 +367,17 @@ class Blueprint return $this->dropIndexCommand('dropIndex', 'index', $index); } + /** + * Indicate that the given fulltext index should be dropped. + * + * @param string|array $index + * @return \Illuminate\Support\Fluent + */ + public function dropFullText($index) + { + return $this->dropIndexCommand('dropFullText', 'fulltext', $index); + } + /** * Indicate that the given spatial index should be dropped. * @@ -377,6 +400,19 @@ class Blueprint return $this->dropIndexCommand('dropForeign', 'foreign', $index); } + /** + * Indicate that the given column and foreign key should be dropped. + * + * @param string $column + * @return \Illuminate\Support\Fluent + */ + public function dropConstrainedForeignId($column) + { + $this->dropForeign([$column]); + + return $this->dropColumn($column); + } + /** * Indicate that the given indexes should be renamed. * @@ -505,6 +541,19 @@ class Blueprint return $this->indexCommand('index', $columns, $name, $algorithm); } + /** + * Specify an fulltext for the table. + * + * @param string|array $columns + * @param string|null $name + * @param string|null $algorithm + * @return \Illuminate\Support\Fluent + */ + public function fullText($columns, $name = null, $algorithm = null) + { + return $this->indexCommand('fulltext', $columns, $name, $algorithm); + } + /** * Specify a spatial index for the table. * @@ -517,16 +566,45 @@ class Blueprint return $this->indexCommand('spatialIndex', $columns, $name); } + /** + * Specify a raw index for the table. + * + * @param string $expression + * @param string $name + * @return \Illuminate\Support\Fluent + */ + public function rawIndex($expression, $name) + { + return $this->index([new Expression($expression)], $name); + } + /** * Specify a foreign key for the table. * * @param string|array $columns * @param string|null $name - * @return \Illuminate\Support\Fluent|\Illuminate\Database\Schema\ForeignKeyDefinition + * @return \Illuminate\Database\Schema\ForeignKeyDefinition */ public function foreign($columns, $name = null) { - return $this->indexCommand('foreign', $columns, $name); + $command = new ForeignKeyDefinition( + $this->indexCommand('foreign', $columns, $name)->getAttributes() + ); + + $this->commands[count($this->commands) - 1] = $command; + + return $command; + } + + /** + * Create a new auto-incrementing big integer (8-byte) column on the table. + * + * @param string $column + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function id($column = 'id') + { + return $this->bigIncrements($column); } /** @@ -623,6 +701,17 @@ class Blueprint return $this->addColumn('string', $column, compact('length')); } + /** + * Create a new tiny text column on the table. + * + * @param string $column + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function tinyText($column) + { + return $this->addColumn('tinyText', $column); + } + /** * Create a new text column on the table. * @@ -781,19 +870,52 @@ class Blueprint return $this->bigInteger($column, $autoIncrement, true); } + /** + * Create a new unsigned big integer (8-byte) column on the table. + * + * @param string $column + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignId($column) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'bigInteger', + 'name' => $column, + 'autoIncrement' => false, + 'unsigned' => true, + ])); + } + + /** + * Create a foreign ID column for the given model. + * + * @param \Illuminate\Database\Eloquent\Model|string $model + * @param string|null $column + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignIdFor($model, $column = null) + { + if (is_string($model)) { + $model = new $model; + } + + return $model->getKeyType() === 'int' && $model->getIncrementing() + ? $this->foreignId($column ?: $model->getForeignKey()) + : $this->foreignUuid($column ?: $model->getForeignKey()); + } + /** * Create a new float column on the table. * * @param string $column * @param int $total * @param int $places + * @param bool $unsigned * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function float($column, $total = 8, $places = 2) + public function float($column, $total = 8, $places = 2, $unsigned = false) { - return $this->addColumn('float', $column, [ - 'total' => $total, 'places' => $places, 'unsigned' => false, - ]); + return $this->addColumn('float', $column, compact('total', 'places', 'unsigned')); } /** @@ -802,13 +924,12 @@ class Blueprint * @param string $column * @param int|null $total * @param int|null $places + * @param bool $unsigned * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function double($column, $total = null, $places = null) + public function double($column, $total = null, $places = null, $unsigned = false) { - return $this->addColumn('double', $column, [ - 'total' => $total, 'places' => $places, 'unsigned' => false, - ]); + return $this->addColumn('double', $column, compact('total', 'places', 'unsigned')); } /** @@ -817,13 +938,38 @@ class Blueprint * @param string $column * @param int $total * @param int $places + * @param bool $unsigned + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function decimal($column, $total = 8, $places = 2, $unsigned = false) + { + return $this->addColumn('decimal', $column, compact('total', 'places', 'unsigned')); + } + + /** + * Create a new unsigned float column on the table. + * + * @param string $column + * @param int $total + * @param int $places + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function unsignedFloat($column, $total = 8, $places = 2) + { + return $this->float($column, $total, $places, true); + } + + /** + * Create a new unsigned double column on the table. + * + * @param string $column + * @param int $total + * @param int $places * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function decimal($column, $total = 8, $places = 2) + public function unsignedDouble($column, $total = null, $places = null) { - return $this->addColumn('decimal', $column, [ - 'total' => $total, 'places' => $places, 'unsigned' => false, - ]); + return $this->double($column, $total, $places, true); } /** @@ -836,9 +982,7 @@ class Blueprint */ public function unsignedDecimal($column, $total = 8, $places = 2) { - return $this->addColumn('decimal', $column, [ - 'total' => $total, 'places' => $places, 'unsigned' => true, - ]); + return $this->decimal($column, $total, $places, true); } /** @@ -1077,6 +1221,20 @@ class Blueprint return $this->addColumn('uuid', $column); } + /** + * Create a new UUID column on the table with a foreign key constraint. + * + * @param string $column + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignUuid($column) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'uuid', + 'name' => $column, + ])); + } + /** * Create a new IP address column on the table. * @@ -1219,6 +1377,38 @@ class Blueprint * @return void */ public function morphs($name, $indexName = null) + { + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->uuidMorphs($name, $indexName); + } else { + $this->numericMorphs($name, $indexName); + } + } + + /** + * Add nullable columns for a polymorphic table. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function nullableMorphs($name, $indexName = null) + { + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->nullableUuidMorphs($name, $indexName); + } else { + $this->nullableNumericMorphs($name, $indexName); + } + } + + /** + * Add the proper columns for a polymorphic table using numeric IDs (incremental). + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function numericMorphs($name, $indexName = null) { $this->string("{$name}_type"); @@ -1228,13 +1418,13 @@ class Blueprint } /** - * Add nullable columns for a polymorphic table. + * Add nullable columns for a polymorphic table using numeric IDs (incremental). * * @param string $name * @param string|null $indexName * @return void */ - public function nullableMorphs($name, $indexName = null) + public function nullableNumericMorphs($name, $indexName = null) { $this->string("{$name}_type")->nullable(); @@ -1354,11 +1544,44 @@ class Blueprint */ public function addColumn($type, $name, array $parameters = []) { - $this->columns[] = $column = new ColumnDefinition( + return $this->addColumnDefinition(new ColumnDefinition( array_merge(compact('type', 'name'), $parameters) - ); + )); + } + + /** + * Add a new column definition to the blueprint. + * + * @param \Illuminate\Database\Schema\ColumnDefinition $definition + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + protected function addColumnDefinition($definition) + { + $this->columns[] = $definition; + + if ($this->after) { + $definition->after($this->after); - return $column; + $this->after = $definition->name; + } + + return $definition; + } + + /** + * Add the columns from the callback after the given column. + * + * @param string $column + * @param \Closure $callback + * @return void + */ + public function after($column, Closure $callback) + { + $this->after = $column; + + $callback($this); + + $this->after = null; } /** @@ -1455,4 +1678,34 @@ class Blueprint return (bool) $column->change; }); } + + /** + * Determine if the blueprint has auto-increment columns. + * + * @return bool + */ + public function hasAutoIncrementColumn() + { + return ! is_null(collect($this->getAddedColumns())->first(function ($column) { + return $column->autoIncrement === true; + })); + } + + /** + * Get the auto-increment column starting values. + * + * @return array + */ + public function autoIncrementingStartingValues() + { + if (! $this->hasAutoIncrementColumn()) { + return []; + } + + return collect($this->getAddedColumns())->mapWithKeys(function ($column) { + return $column->autoIncrement === true + ? [$column->name => $column->get('startingValue', $column->get('from'))] + : [$column->name => null]; + })->filter()->all(); + } } diff --git a/src/Illuminate/Database/Schema/Builder.php b/src/Illuminate/Database/Schema/Builder.php index 93571b2c4a9ac854914bb351609f94b5662e5aa8..40f78880b8ce40f1d5f5ae3da637ee78873aec30 100755 --- a/src/Illuminate/Database/Schema/Builder.php +++ b/src/Illuminate/Database/Schema/Builder.php @@ -3,10 +3,10 @@ namespace Illuminate\Database\Schema; use Closure; -use Doctrine\DBAL\Types\Type; +use Illuminate\Container\Container; use Illuminate\Database\Connection; +use InvalidArgumentException; use LogicException; -use RuntimeException; class Builder { @@ -38,6 +38,13 @@ class Builder */ public static $defaultStringLength = 255; + /** + * The default relationship morph key type. + * + * @var string + */ + public static $defaultMorphKeyType = 'int'; + /** * Create a new database Schema manager. * @@ -61,6 +68,59 @@ class Builder static::$defaultStringLength = $length; } + /** + * Set the default morph key type for migrations. + * + * @param string $type + * @return void + * + * @throws \InvalidArgumentException + */ + public static function defaultMorphKeyType(string $type) + { + if (! in_array($type, ['int', 'uuid'])) { + throw new InvalidArgumentException("Morph key type must be 'int' or 'uuid'."); + } + + static::$defaultMorphKeyType = $type; + } + + /** + * Set the default morph key type for migrations to UUIDs. + * + * @return void + */ + public static function morphUsingUuids() + { + return static::defaultMorphKeyType('uuid'); + } + + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + * + * @throws \LogicException + */ + public function createDatabase($name) + { + throw new LogicException('This database driver does not support creating databases.'); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + * + * @throws \LogicException + */ + public function dropDatabaseIfExists($name) + { + throw new LogicException('This database driver does not support dropping databases.'); + } + /** * Determine if the given table exists. * @@ -193,6 +253,20 @@ class Builder })); } + /** + * Drop columns from a table schema. + * + * @param string $table + * @param string|array $columns + * @return void + */ + public function dropColumns($table, $columns) + { + $this->table($table, function (Blueprint $blueprint) use ($columns) { + $blueprint->dropColumn($columns); + }); + } + /** * Drop all tables from the database. * @@ -307,7 +381,7 @@ class Builder return call_user_func($this->resolver, $table, $callback, $prefix); } - return new Blueprint($table, $callback, $prefix); + return Container::getInstance()->make(Blueprint::class, compact('table', 'callback', 'prefix')); } /** @@ -317,26 +391,10 @@ class Builder * @param string $name * @param string $type * @return void - * - * @throws \Doctrine\DBAL\DBALException - * @throws \RuntimeException */ public function registerCustomDoctrineType($class, $name, $type) { - if (! $this->connection->isDoctrineAvailable()) { - throw new RuntimeException( - 'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).' - ); - } - - if (! Type::hasType($name)) { - Type::addType($name, $class); - - $this->connection - ->getDoctrineSchemaManager() - ->getDatabasePlatform() - ->registerDoctrineTypeMapping($type, $name); - } + $this->connection->registerDoctrineType($class, $name, $type); } /** diff --git a/src/Illuminate/Database/Schema/ColumnDefinition.php b/src/Illuminate/Database/Schema/ColumnDefinition.php index ae72c527c337313e0660e7da047fa53154fde4e4..85f8ba38c9066a4459de958936b5225de69354fc 100644 --- a/src/Illuminate/Database/Schema/ColumnDefinition.php +++ b/src/Illuminate/Database/Schema/ColumnDefinition.php @@ -2,30 +2,35 @@ namespace Illuminate\Database\Schema; -use Illuminate\Database\Query\Expression; use Illuminate\Support\Fluent; /** - * @method ColumnDefinition after(string $column) Place the column "after" another column (MySQL) - * @method ColumnDefinition always() Used as a modifier for generatedAs() (PostgreSQL) - * @method ColumnDefinition autoIncrement() Set INTEGER columns as auto-increment (primary key) - * @method ColumnDefinition change() Change the column - * @method ColumnDefinition charset(string $charset) Specify a character set for the column (MySQL) - * @method ColumnDefinition collation(string $collation) Specify a collation for the column (MySQL/PostgreSQL/SQL Server) - * @method ColumnDefinition comment(string $comment) Add a comment to the column (MySQL) - * @method ColumnDefinition default(mixed $value) Specify a "default" value for the column - * @method ColumnDefinition first() Place the column "first" in the table (MySQL) - * @method ColumnDefinition generatedAs(string|Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) - * @method ColumnDefinition index(string $indexName = null) Add an index - * @method ColumnDefinition nullable(bool $value = true) Allow NULL values to be inserted into the column - * @method ColumnDefinition primary() Add a primary index - * @method ColumnDefinition spatialIndex() Add a spatial index - * @method ColumnDefinition storedAs(string $expression) Create a stored generated column (MySQL) - * @method ColumnDefinition unique() Add a unique index - * @method ColumnDefinition unsigned() Set the INTEGER column as UNSIGNED (MySQL) - * @method ColumnDefinition useCurrent() Set the TIMESTAMP column to use CURRENT_TIMESTAMP as default value - * @method ColumnDefinition virtualAs(string $expression) Create a virtual generated column (MySQL) - * @method ColumnDefinition persisted() Mark the computed generated column as persistent (SQL Server) + * @method $this after(string $column) Place the column "after" another column (MySQL) + * @method $this always() Used as a modifier for generatedAs() (PostgreSQL) + * @method $this autoIncrement() Set INTEGER columns as auto-increment (primary key) + * @method $this change() Change the column + * @method $this charset(string $charset) Specify a character set for the column (MySQL) + * @method $this collation(string $collation) Specify a collation for the column (MySQL/PostgreSQL/SQL Server) + * @method $this comment(string $comment) Add a comment to the column (MySQL/PostgreSQL) + * @method $this default(mixed $value) Specify a "default" value for the column + * @method $this first() Place the column "first" in the table (MySQL) + * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL / PostgreSQL) + * @method $this generatedAs(string|Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) + * @method $this index(string $indexName = null) Add an index + * @method $this invisible() Specify that the column should be invisible to "SELECT *" (MySQL) + * @method $this nullable(bool $value = true) Allow NULL values to be inserted into the column + * @method $this persisted() Mark the computed generated column as persistent (SQL Server) + * @method $this primary() Add a primary index + * @method $this fulltext(string $indexName = null) Add a fulltext index + * @method $this spatialIndex(string $indexName = null) Add a spatial index + * @method $this startingValue(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) + * @method $this storedAs(string $expression) Create a stored generated column (MySQL/PostgreSQL/SQLite) + * @method $this type(string $type) Specify a type for the column + * @method $this unique(string $indexName = null) Add a unique index + * @method $this unsigned() Set the INTEGER column as UNSIGNED (MySQL) + * @method $this useCurrent() Set the TIMESTAMP column to use CURRENT_TIMESTAMP as default value + * @method $this useCurrentOnUpdate() Set the TIMESTAMP column to use CURRENT_TIMESTAMP when updating (MySQL) + * @method $this virtualAs(string $expression) Create a virtual generated column (MySQL/PostgreSQL/SQLite) */ class ColumnDefinition extends Fluent { diff --git a/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..1a2059eee3bb1333d35d1e5b93706be79730fd49 --- /dev/null +++ b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php @@ -0,0 +1,52 @@ +<?php + +namespace Illuminate\Database\Schema; + +use Illuminate\Support\Str; + +class ForeignIdColumnDefinition extends ColumnDefinition +{ + /** + * The schema builder blueprint instance. + * + * @var \Illuminate\Database\Schema\Blueprint + */ + protected $blueprint; + + /** + * Create a new foreign ID column definition. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param array $attributes + * @return void + */ + public function __construct(Blueprint $blueprint, $attributes = []) + { + parent::__construct($attributes); + + $this->blueprint = $blueprint; + } + + /** + * Create a foreign key constraint on this column referencing the "id" column of the conventionally related table. + * + * @param string|null $table + * @param string $column + * @return \Illuminate\Database\Schema\ForeignKeyDefinition + */ + public function constrained($table = null, $column = 'id') + { + return $this->references($column)->on($table ?? Str::plural(Str::beforeLast($this->name, '_'.$column))); + } + + /** + * Specify which column this foreign ID references on another table. + * + * @param string $column + * @return \Illuminate\Database\Schema\ForeignKeyDefinition + */ + public function references($column) + { + return $this->blueprint->foreign($this->name)->references($column); + } +} diff --git a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php index 79cc3d4737b2e6dca722b6f41447dbe42e0bfc33..a03fcff777538dda24f9c0d9010b3bb8050f994c 100644 --- a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php +++ b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php @@ -5,14 +5,62 @@ namespace Illuminate\Database\Schema; use Illuminate\Support\Fluent; /** - * @method ForeignKeyDefinition references(string|array $columns) Specify the referenced column(s) + * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) + * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) * @method ForeignKeyDefinition on(string $table) Specify the referenced table * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) - * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) + * @method ForeignKeyDefinition references(string|array $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { - // + /** + * Indicate that updates should cascade. + * + * @return $this + */ + public function cascadeOnUpdate() + { + return $this->onUpdate('cascade'); + } + + /** + * Indicate that updates should be restricted. + * + * @return $this + */ + public function restrictOnUpdate() + { + return $this->onUpdate('restrict'); + } + + /** + * Indicate that deletes should cascade. + * + * @return $this + */ + public function cascadeOnDelete() + { + return $this->onDelete('cascade'); + } + + /** + * Indicate that deletes should be restricted. + * + * @return $this + */ + public function restrictOnDelete() + { + return $this->onDelete('restrict'); + } + + /** + * Indicate that deletes should set the foreign key value to null. + * + * @return $this + */ + public function nullOnDelete() + { + return $this->onDelete('set null'); + } } diff --git a/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php b/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php index 42f322db9713327cbcac27c10b5740102f436551..260935f8a71eecd59670ce4ea8997467f2d5ed3d 100644 --- a/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php +++ b/src/Illuminate/Database/Schema/Grammars/ChangeColumn.php @@ -33,12 +33,16 @@ class ChangeColumn )); } + $schema = $connection->getDoctrineSchemaManager(); + $databasePlatform = $schema->getDatabasePlatform(); + $databasePlatform->registerDoctrineTypeMapping('enum', 'string'); + $tableDiff = static::getChangedDiff( - $grammar, $blueprint, $schema = $connection->getDoctrineSchemaManager() + $grammar, $blueprint, $schema ); if ($tableDiff !== false) { - return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); + return (array) $databasePlatform->getAlterTableSQL($tableDiff); } return []; @@ -155,6 +159,9 @@ class ChangeColumn case 'binary': $type = 'blob'; break; + case 'uuid': + $type = 'guid'; + break; } return Type::getType($type); @@ -191,6 +198,7 @@ class ChangeColumn 'binary', 'boolean', 'date', + 'dateTime', 'decimal', 'double', 'float', diff --git a/src/Illuminate/Database/Schema/Grammars/Grammar.php b/src/Illuminate/Database/Schema/Grammars/Grammar.php index b60dfe817b627967169c300ada8d82eeea0cfdb2..7313576b4f18f9076fd626dcbf85c82973df5152 100755 --- a/src/Illuminate/Database/Schema/Grammars/Grammar.php +++ b/src/Illuminate/Database/Schema/Grammars/Grammar.php @@ -9,6 +9,7 @@ use Illuminate\Database\Grammar as BaseGrammar; use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Fluent; +use LogicException; use RuntimeException; abstract class Grammar extends BaseGrammar @@ -27,6 +28,33 @@ abstract class Grammar extends BaseGrammar */ protected $fluentCommands = []; + /** + * Compile a create database command. + * + * @param string $name + * @param \Illuminate\Database\Connection $connection + * @return void + * + * @throws \LogicException + */ + public function compileCreateDatabase($name, $connection) + { + throw new LogicException('This database driver does not support creating databases.'); + } + + /** + * Compile a drop database if exists command. + * + * @param string $name + * @return void + * + * @throws \LogicException + */ + public function compileDropDatabaseIfExists($name) + { + throw new LogicException('This database driver does not support dropping databases.'); + } + /** * Compile a rename column command. * @@ -55,6 +83,32 @@ abstract class Grammar extends BaseGrammar return ChangeColumn::compile($this, $blueprint, $command, $connection); } + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + * + * @throws \RuntimeException + */ + public function compileFulltext(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('This database driver does not support fulltext index creation.'); + } + + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('This database driver does not support fulltext index creation.'); + } + /** * Compile a foreign key command. * diff --git a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php index 6464a5d807d42439f1caadfdf08d8cbcfee52075..1f64cf31e14cb4d6b79b9f4ac2538020087a54a3 100755 --- a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php @@ -12,20 +12,51 @@ class MySqlGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ protected $modifiers = [ - 'Unsigned', 'Charset', 'Collate', 'VirtualAs', 'StoredAs', 'Nullable', + 'Unsigned', 'Charset', 'Collate', 'VirtualAs', 'StoredAs', 'Nullable', 'Invisible', 'Srid', 'Default', 'Increment', 'Comment', 'After', 'First', ]; /** * The possible column serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; + /** + * Compile a create database command. + * + * @param string $name + * @param \Illuminate\Database\Connection $connection + * @return string + */ + public function compileCreateDatabase($name, $connection) + { + return sprintf( + 'create database %s default character set %s default collate %s', + $this->wrapValue($name), + $this->wrapValue($connection->getConfig('charset')), + $this->wrapValue($connection->getConfig('collation')), + ); + } + + /** + * Compile a drop database if exists command. + * + * @param string $name + * @return string + */ + public function compileDropDatabaseIfExists($name) + { + return sprintf( + 'drop database if exists %s', + $this->wrapValue($name) + ); + } + /** * Compile the query to determine the list of tables. * @@ -52,7 +83,7 @@ class MySqlGrammar extends Grammar * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @param \Illuminate\Database\Connection $connection - * @return string + * @return array */ public function compileCreate(Blueprint $blueprint, Fluent $command, Connection $connection) { @@ -70,9 +101,9 @@ class MySqlGrammar extends Grammar // Finally, we will append the engine configuration onto this SQL statement as // the final thing we do before returning this finished SQL. Once this gets // added the query will be ready to execute against the real connections. - return $this->compileCreateEngine( + return array_values(array_filter(array_merge([$this->compileCreateEngine( $sql, $connection, $blueprint - ); + )], $this->compileAutoIncrementStartingValues($blueprint)))); } /** @@ -81,15 +112,15 @@ class MySqlGrammar extends Grammar * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @param \Illuminate\Database\Connection $connection - * @return string + * @return array */ protected function compileCreateTable($blueprint, $command, $connection) { - return sprintf('%s table %s (%s)', + return trim(sprintf('%s table %s (%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), implode(', ', $this->getColumns($blueprint)) - ); + )); } /** @@ -147,13 +178,29 @@ class MySqlGrammar extends Grammar * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return string + * @return array */ public function compileAdd(Blueprint $blueprint, Fluent $command) { $columns = $this->prefixArray('add', $this->getColumns($blueprint)); - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + return array_values(array_merge( + ['alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns)], + $this->compileAutoIncrementStartingValues($blueprint) + )); + } + + /** + * Compile the auto-incrementing column starting values. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @return array + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint) + { + return collect($blueprint->autoIncrementingStartingValues())->map(function ($value, $column) use ($blueprint) { + return 'alter table '.$this->wrapTable($blueprint->getTable()).' auto_increment = '.$value; + })->all(); } /** @@ -194,6 +241,18 @@ class MySqlGrammar extends Grammar return $this->compileKey($blueprint, $command, 'index'); } + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileKey($blueprint, $command, 'fulltext'); + } + /** * Compile a spatial index key command. * @@ -303,6 +362,18 @@ class MySqlGrammar extends Grammar return "alter table {$this->wrapTable($blueprint)} drop index {$index}"; } + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileDropIndex($blueprint, $command); + } + /** * Compile a drop spatial index command. * @@ -443,6 +514,17 @@ class MySqlGrammar extends Grammar return "varchar({$column->length})"; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'tinytext'; + } + /** * Create the column definition for a text type. * @@ -644,7 +726,11 @@ class MySqlGrammar extends Grammar { $columnType = $column->precision ? "datetime($column->precision)" : 'datetime'; - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + $current = $column->precision ? "CURRENT_TIMESTAMP($column->precision)" : 'CURRENT_TIMESTAMP'; + + $columnType = $column->useCurrent ? "$columnType default $current" : $columnType; + + return $column->useCurrentOnUpdate ? "$columnType on update $current" : $columnType; } /** @@ -690,7 +776,11 @@ class MySqlGrammar extends Grammar { $columnType = $column->precision ? "timestamp($column->precision)" : 'timestamp'; - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + $current = $column->precision ? "CURRENT_TIMESTAMP($column->precision)" : 'CURRENT_TIMESTAMP'; + + $columnType = $column->useCurrent ? "$columnType default $current" : $columnType; + + return $column->useCurrentOnUpdate ? "$columnType on update $current" : $columnType; } /** @@ -948,6 +1038,20 @@ class MySqlGrammar extends Grammar } } + /** + * Get the SQL for an invisible column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyInvisible(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->invisible)) { + return ' invisible'; + } + } + /** * Get the SQL for a default column modifier. * diff --git a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php index 0c1dd2e595a172c33933d531eede77aedf24e051..27490bc44f7f1e61fa20b227a732b99dd655d9dd 100755 --- a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php @@ -17,24 +17,54 @@ class PostgresGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ protected $modifiers = ['Collate', 'Increment', 'Nullable', 'Default', 'VirtualAs', 'StoredAs']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; /** * The commands to be executed outside of create or alter command. * - * @var array + * @var string[] */ protected $fluentCommands = ['Comment']; + /** + * Compile a create database command. + * + * @param string $name + * @param \Illuminate\Database\Connection $connection + * @return string + */ + public function compileCreateDatabase($name, $connection) + { + return sprintf( + 'create database %s encoding %s', + $this->wrapValue($name), + $this->wrapValue($connection->getConfig('charset')), + ); + } + + /** + * Compile a drop database if exists command. + * + * @param string $name + * @return string + */ + public function compileDropDatabaseIfExists($name) + { + return sprintf( + 'drop database if exists %s', + $this->wrapValue($name) + ); + } + /** * Compile the query to determine if a table exists. * @@ -60,15 +90,15 @@ class PostgresGrammar extends Grammar * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return string + * @return array */ public function compileCreate(Blueprint $blueprint, Fluent $command) { - return sprintf('%s table %s (%s)', + return array_values(array_filter(array_merge([sprintf('%s table %s (%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), implode(', ', $this->getColumns($blueprint)) - ); + )], $this->compileAutoIncrementStartingValues($blueprint)))); } /** @@ -80,10 +110,23 @@ class PostgresGrammar extends Grammar */ public function compileAdd(Blueprint $blueprint, Fluent $command) { - return sprintf('alter table %s %s', + return array_values(array_filter(array_merge([sprintf('alter table %s %s', $this->wrapTable($blueprint), implode(', ', $this->prefixArray('add column', $this->getColumns($blueprint))) - ); + )], $this->compileAutoIncrementStartingValues($blueprint)))); + } + + /** + * Compile the auto-incrementing column starting values. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @return array + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint) + { + return collect($blueprint->autoIncrementingStartingValues())->map(function ($value, $column) use ($blueprint) { + return 'alter sequence '.$blueprint->getTable().'_'.$column.'_seq restart with '.$value; + })->all(); } /** @@ -133,6 +176,30 @@ class PostgresGrammar extends Grammar ); } + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + * + * @throws \RuntimeException + */ + public function compileFulltext(Blueprint $blueprint, Fluent $command) + { + $language = $command->language ?: 'english'; + + $columns = array_map(function ($column) use ($language) { + return "to_tsvector({$this->quoteString($language)}, {$this->wrap($column)})"; + }, $command->columns); + + return sprintf('create index %s on %s using gin ((%s))', + $this->wrap($command->index), + $this->wrapTable($blueprint), + implode(' || ', $columns) + ); + } + /** * Compile a spatial index key command. * @@ -316,6 +383,18 @@ class PostgresGrammar extends Grammar return "drop index {$this->wrap($command->index)}"; } + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileDropIndex($blueprint, $command); + } + /** * Compile a drop spatial index command. * @@ -429,6 +508,17 @@ class PostgresGrammar extends Grammar return "varchar({$column->length})"; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'varchar(255)'; + } + /** * Create the column definition for a text type. * diff --git a/src/Illuminate/Database/Schema/Grammars/RenameColumn.php b/src/Illuminate/Database/Schema/Grammars/RenameColumn.php index fd54fb28df1410b5cbd7b095a1af6e655303d8bb..0db0c507e404e36ef100ca86aa481e01de01926b 100644 --- a/src/Illuminate/Database/Schema/Grammars/RenameColumn.php +++ b/src/Illuminate/Database/Schema/Grammars/RenameColumn.php @@ -22,13 +22,15 @@ class RenameColumn */ public static function compile(Grammar $grammar, Blueprint $blueprint, Fluent $command, Connection $connection) { + $schema = $connection->getDoctrineSchemaManager(); + $databasePlatform = $schema->getDatabasePlatform(); + $databasePlatform->registerDoctrineTypeMapping('enum', 'string'); + $column = $connection->getDoctrineColumn( $grammar->getTablePrefix().$blueprint->getTable(), $command->from ); - $schema = $connection->getDoctrineSchemaManager(); - - return (array) $schema->getDatabasePlatform()->getAlterTableSQL(static::getRenamedDiff( + return (array) $databasePlatform->getAlterTableSQL(static::getRenamedDiff( $grammar, $blueprint, $command, $column, $schema )); } diff --git a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php index c52cd8ff8868711d9f41ea38518b3c313294d753..e699ee68baac6ccae230307ae2e6d79386d24d3b 100755 --- a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php @@ -14,14 +14,14 @@ class SQLiteGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ - protected $modifiers = ['Nullable', 'Default', 'Increment']; + protected $modifiers = ['VirtualAs', 'StoredAs', 'Nullable', 'Default', 'Increment']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; @@ -137,7 +137,9 @@ class SQLiteGrammar extends Grammar { $columns = $this->prefixArray('add column', $this->getColumns($blueprint)); - return collect($columns)->map(function ($column) use ($blueprint) { + return collect($columns)->reject(function ($column) { + return preg_match('/as \(.*\) stored/', $column) > 0; + })->map(function ($column) use ($blueprint) { return 'alter table '.$this->wrapTable($blueprint).' '.$column; })->all(); } @@ -430,6 +432,17 @@ class SQLiteGrammar extends Grammar return 'varchar'; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'text'; + } + /** * Create the column definition for a text type. * @@ -464,7 +477,7 @@ class SQLiteGrammar extends Grammar } /** - * Create the column definition for a integer type. + * Create the column definition for an integer type. * * @param \Illuminate\Support\Fluent $column * @return string @@ -625,6 +638,7 @@ class SQLiteGrammar extends Grammar * Create the column definition for a date-time (with time zone) type. * * Note: "SQLite does not have a storage class set aside for storing dates and/or times." + * * @link https://www.sqlite.org/datatype3.html * * @param \Illuminate\Support\Fluent $column @@ -822,6 +836,47 @@ class SQLiteGrammar extends Grammar return 'multipolygon'; } + /** + * Create the column definition for a generated, computed column type. + * + * @param \Illuminate\Support\Fluent $column + * @return void + * + * @throws \RuntimeException + */ + protected function typeComputed(Fluent $column) + { + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); + } + + /** + * Get the SQL for a generated virtual column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->virtualAs)) { + return " as ({$column->virtualAs})"; + } + } + + /** + * Get the SQL for a generated stored column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->storedAs)) { + return " as ({$column->storedAs}) stored"; + } + } + /** * Get the SQL for a nullable column modifier. * @@ -831,7 +886,13 @@ class SQLiteGrammar extends Grammar */ protected function modifyNullable(Blueprint $blueprint, Fluent $column) { - return $column->nullable ? ' null' : ' not null'; + if (is_null($column->virtualAs) && is_null($column->storedAs)) { + return $column->nullable ? '' : ' not null'; + } + + if ($column->nullable === false) { + return ' not null'; + } } /** @@ -843,7 +904,7 @@ class SQLiteGrammar extends Grammar */ protected function modifyDefault(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->default)) { + if (! is_null($column->default) && is_null($column->virtualAs) && is_null($column->storedAs)) { return ' default '.$this->getDefaultValue($column->default); } } diff --git a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php index f30be675939ff1c13b78a87486c534252ac198b8..e594cfd99226a543eb485e47b0021e6e1e7a8f20 100755 --- a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php @@ -17,17 +17,46 @@ class SqlServerGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ protected $modifiers = ['Increment', 'Collate', 'Nullable', 'Default', 'Persisted']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['tinyInteger', 'smallInteger', 'mediumInteger', 'integer', 'bigInteger']; + /** + * Compile a create database command. + * + * @param string $name + * @param \Illuminate\Database\Connection $connection + * @return string + */ + public function compileCreateDatabase($name, $connection) + { + return sprintf( + 'create database %s', + $this->wrapValue($name), + ); + } + + /** + * Compile a drop database if exists command. + * + * @param string $name + * @return string + */ + public function compileDropDatabaseIfExists($name) + { + return sprintf( + 'drop database if exists %s', + $this->wrapValue($name) + ); + } + /** * Compile the query to determine if a table exists. * @@ -35,7 +64,7 @@ class SqlServerGrammar extends Grammar */ public function compileTableExists() { - return "select * from sysobjects where type = 'U' and name = ?"; + return "select * from sys.sysobjects where id = object_id(?) and xtype in ('U', 'V')"; } /** @@ -46,9 +75,7 @@ class SqlServerGrammar extends Grammar */ public function compileColumnListing($table) { - return "select col.name from sys.columns as col - join sys.objects as obj on col.object_id = obj.object_id - where obj.type = 'U' and obj.name = '$table'"; + return "select name from sys.columns where object_id = object_id('$table')"; } /** @@ -165,7 +192,7 @@ class SqlServerGrammar extends Grammar */ public function compileDropIfExists(Blueprint $blueprint, Fluent $command) { - return sprintf('if exists (select * from INFORMATION_SCHEMA.TABLES where TABLE_NAME = %s) drop table %s', + return sprintf('if exists (select * from sys.sysobjects where id = object_id(%s, \'U\')) drop table %s', "'".str_replace("'", "''", $this->getTablePrefix().$blueprint->getTable())."'", $this->wrapTable($blueprint) ); @@ -212,7 +239,7 @@ class SqlServerGrammar extends Grammar $sql = "DECLARE @sql NVARCHAR(MAX) = '';"; $sql .= "SELECT @sql += 'ALTER TABLE [dbo].[{$tableName}] DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' "; - $sql .= 'FROM SYS.COLUMNS '; + $sql .= 'FROM sys.columns '; $sql .= "WHERE [object_id] = OBJECT_ID('[dbo].[{$tableName}]') AND [name] in ({$columns}) AND [default_object_id] <> 0;"; $sql .= 'EXEC(@sql)'; @@ -388,6 +415,17 @@ class SqlServerGrammar extends Grammar return "nvarchar({$column->length})"; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'nvarchar(255)'; + } + /** * Create the column definition for a text type. * diff --git a/src/Illuminate/Database/Schema/MySqlBuilder.php b/src/Illuminate/Database/Schema/MySqlBuilder.php index f07946c85e233adb8341ae49ae49aa59fa8ed790..699b41d5f2273c5c8ba980116cd31a93a3a4192f 100755 --- a/src/Illuminate/Database/Schema/MySqlBuilder.php +++ b/src/Illuminate/Database/Schema/MySqlBuilder.php @@ -4,6 +4,32 @@ namespace Illuminate\Database\Schema; class MySqlBuilder extends Builder { + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name, $this->connection) + ); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return $this->connection->statement( + $this->grammar->compileDropDatabaseIfExists($name) + ); + } + /** * Determine if the given table exists. * diff --git a/src/Illuminate/Database/Schema/MySqlSchemaState.php b/src/Illuminate/Database/Schema/MySqlSchemaState.php new file mode 100644 index 0000000000000000000000000000000000000000..e0b0103d3e26b019ba36845de5fba9352f6c6c24 --- /dev/null +++ b/src/Illuminate/Database/Schema/MySqlSchemaState.php @@ -0,0 +1,163 @@ +<?php + +namespace Illuminate\Database\Schema; + +use Exception; +use Illuminate\Database\Connection; +use Illuminate\Support\Str; +use Symfony\Component\Process\Process; + +class MySqlSchemaState extends SchemaState +{ + /** + * Dump the database's schema into a file. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + public function dump(Connection $connection, $path) + { + $this->executeDumpProcess($this->makeProcess( + $this->baseDumpCommand().' --routines --result-file="${:LARAVEL_LOAD_PATH}" --no-data' + ), $this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + + $this->removeAutoIncrementingState($path); + + $this->appendMigrationData($path); + } + + /** + * Remove the auto-incrementing state from the given schema dump. + * + * @param string $path + * @return void + */ + protected function removeAutoIncrementingState(string $path) + { + $this->files->put($path, preg_replace( + '/\s+AUTO_INCREMENT=[0-9]+/iu', + '', + $this->files->get($path) + )); + } + + /** + * Append the migration data to the schema dump. + * + * @param string $path + * @return void + */ + protected function appendMigrationData(string $path) + { + $process = $this->executeDumpProcess($this->makeProcess( + $this->baseDumpCommand().' '.$this->migrationTable.' --no-create-info --skip-extended-insert --skip-routines --compact' + ), null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $this->files->append($path, $process->getOutput()); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $command = 'mysql '.$this->connectionString().' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + + $process = $this->makeProcess($command)->setTimeout(null); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for MySQL as a string. + * + * @return string + */ + protected function baseDumpCommand() + { + $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0'; + + if (! $this->connection->isMaria()) { + $command .= ' --set-gtid-purged=OFF'; + } + + return $command.' "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Generate a basic connection string (--socket, --host, --port, --user, --password) for the database. + * + * @return string + */ + protected function connectionString() + { + $value = ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}"'; + + $value .= $this->connection->getConfig()['unix_socket'] ?? false + ? ' --socket="${:LARAVEL_LOAD_SOCKET}"' + : ' --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"'; + + return $value; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + $config['host'] = $config['host'] ?? ''; + + return [ + 'LARAVEL_LOAD_SOCKET' => $config['unix_socket'] ?? '', + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', + 'LARAVEL_LOAD_USER' => $config['username'], + 'LARAVEL_LOAD_PASSWORD' => $config['password'] ?? '', + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } + + /** + * Execute the given dump process. + * + * @param \Symfony\Component\Process\Process $process + * @param callable $output + * @param array $variables + * @return \Symfony\Component\Process\Process + */ + protected function executeDumpProcess(Process $process, $output, array $variables) + { + try { + $process->setTimeout(null)->mustRun($output, $variables); + } catch (Exception $e) { + if (Str::contains($e->getMessage(), ['column-statistics', 'column_statistics'])) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --column-statistics=0', '', $process->getCommandLine()) + ), $output, $variables); + } + + if (Str::contains($e->getMessage(), ['set-gtid-purged'])) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --set-gtid-purged=OFF', '', $process->getCommandLine()) + ), $output, $variables); + } + + throw $e; + } + + return $process; + } +} diff --git a/src/Illuminate/Database/Schema/PostgresBuilder.php b/src/Illuminate/Database/Schema/PostgresBuilder.php index 76673a719a416cccf761bd688c876b353111355d..ce1b5770ad5af85ff7703d21e67cef14c048cd04 100755 --- a/src/Illuminate/Database/Schema/PostgresBuilder.php +++ b/src/Illuminate/Database/Schema/PostgresBuilder.php @@ -4,6 +4,32 @@ namespace Illuminate\Database\Schema; class PostgresBuilder extends Builder { + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name, $this->connection) + ); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return $this->connection->statement( + $this->grammar->compileDropDatabaseIfExists($name) + ); + } + /** * Determine if the given table exists. * diff --git a/src/Illuminate/Database/Schema/PostgresSchemaState.php b/src/Illuminate/Database/Schema/PostgresSchemaState.php new file mode 100644 index 0000000000000000000000000000000000000000..3e2f5666e9cd11cb2319d38ba6a7268e5d9bbbe0 --- /dev/null +++ b/src/Illuminate/Database/Schema/PostgresSchemaState.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Database\Schema; + +use Illuminate\Database\Connection; +use Illuminate\Support\Str; + +class PostgresSchemaState extends SchemaState +{ + /** + * Dump the database's schema into a file. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + public function dump(Connection $connection, $path) + { + $excludedTables = collect($connection->getSchemaBuilder()->getAllTables()) + ->map->tablename + ->reject(function ($table) { + return $table === $this->migrationTable; + })->map(function ($table) { + return '--exclude-table-data="*.'.$table.'"'; + })->implode(' '); + + $this->makeProcess( + $this->baseDumpCommand().' --file="${:LARAVEL_LOAD_PATH}" '.$excludedTables + )->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $command = 'pg_restore --no-owner --no-acl --clean --if-exists --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}" "${:LARAVEL_LOAD_PATH}"'; + + if (Str::endsWith($path, '.sql')) { + $command = 'psql --file="${:LARAVEL_LOAD_PATH}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + $process = $this->makeProcess($command); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for PostgreSQL as a string. + * + * @return string + */ + protected function baseDumpCommand() + { + return 'pg_dump --no-owner --no-acl -Fc --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + $config['host'] = $config['host'] ?? ''; + + return [ + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'], + 'LARAVEL_LOAD_USER' => $config['username'], + 'PGPASSWORD' => $config['password'], + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/src/Illuminate/Database/Schema/SQLiteBuilder.php b/src/Illuminate/Database/Schema/SQLiteBuilder.php index 78b6b9c78d2ec1c57e83cac7ba865f4c360fa008..3bc1275c6e048a083fedf3572f6572e6b291e2a2 100644 --- a/src/Illuminate/Database/Schema/SQLiteBuilder.php +++ b/src/Illuminate/Database/Schema/SQLiteBuilder.php @@ -2,8 +2,34 @@ namespace Illuminate\Database\Schema; +use Illuminate\Support\Facades\File; + class SQLiteBuilder extends Builder { + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return File::put($name, '') !== false; + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return File::exists($name) + ? File::delete($name) + : true; + } + /** * Drop all tables from the database. * diff --git a/src/Illuminate/Database/Schema/SchemaState.php b/src/Illuminate/Database/Schema/SchemaState.php new file mode 100644 index 0000000000000000000000000000000000000000..e6f35ab91fe939ccee3eb57ab0d4dc519348eb5c --- /dev/null +++ b/src/Illuminate/Database/Schema/SchemaState.php @@ -0,0 +1,122 @@ +<?php + +namespace Illuminate\Database\Schema; + +use Illuminate\Database\Connection; +use Illuminate\Filesystem\Filesystem; +use Symfony\Component\Process\Process; + +abstract class SchemaState +{ + /** + * The connection instance. + * + * @var \Illuminate\Database\Connection + */ + protected $connection; + + /** + * The filesystem instance. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $files; + + /** + * The name of the application's migration table. + * + * @var string + */ + protected $migrationTable = 'migrations'; + + /** + * The process factory callback. + * + * @var callable + */ + protected $processFactory; + + /** + * The output callable instance. + * + * @var callable + */ + protected $output; + + /** + * Create a new dumper instance. + * + * @param \Illuminate\Database\Connection $connection + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return void + */ + public function __construct(Connection $connection, Filesystem $files = null, callable $processFactory = null) + { + $this->connection = $connection; + + $this->files = $files ?: new Filesystem; + + $this->processFactory = $processFactory ?: function (...$arguments) { + return Process::fromShellCommandline(...$arguments)->setTimeout(null); + }; + + $this->handleOutputUsing(function () { + // + }); + } + + /** + * Dump the database's schema into a file. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + abstract public function dump(Connection $connection, $path); + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + abstract public function load($path); + + /** + * Create a new process instance. + * + * @param array $arguments + * @return \Symfony\Component\Process\Process + */ + public function makeProcess(...$arguments) + { + return call_user_func($this->processFactory, ...$arguments); + } + + /** + * Specify the name of the application's migration table. + * + * @param string $table + * @return $this + */ + public function withMigrationTable(string $table) + { + $this->migrationTable = $table; + + return $this; + } + + /** + * Specify the callback that should be used to handle process output. + * + * @param callable $output + * @return $this + */ + public function handleOutputUsing(callable $output) + { + $this->output = $output; + + return $this; + } +} diff --git a/src/Illuminate/Database/Schema/SqlServerBuilder.php b/src/Illuminate/Database/Schema/SqlServerBuilder.php index 0b3e47bec9a61013847a96ce3b37bc17dc7399e9..93da1cb86fad0a4056e9df8c84ed5af339504504 100644 --- a/src/Illuminate/Database/Schema/SqlServerBuilder.php +++ b/src/Illuminate/Database/Schema/SqlServerBuilder.php @@ -4,6 +4,32 @@ namespace Illuminate\Database\Schema; class SqlServerBuilder extends Builder { + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name, $this->connection) + ); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return $this->connection->statement( + $this->grammar->compileDropDatabaseIfExists($name) + ); + } + /** * Drop all tables from the database. * diff --git a/src/Illuminate/Database/Schema/SqliteSchemaState.php b/src/Illuminate/Database/Schema/SqliteSchemaState.php new file mode 100644 index 0000000000000000000000000000000000000000..9a98b6331cbab35027f545e6ad4bc69dd6d88d51 --- /dev/null +++ b/src/Illuminate/Database/Schema/SqliteSchemaState.php @@ -0,0 +1,93 @@ +<?php + +namespace Illuminate\Database\Schema; + +use Illuminate\Database\Connection; + +class SqliteSchemaState extends SchemaState +{ + /** + * Dump the database's schema into a file. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + public function dump(Connection $connection, $path) + { + with($process = $this->makeProcess( + $this->baseCommand().' .schema' + ))->setTimeout(null)->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) { + return stripos($line, 'sqlite_sequence') === false && + strlen($line) > 0; + })->all(); + + $this->files->put($path, implode(PHP_EOL, $migrations).PHP_EOL); + + $this->appendMigrationData($path); + } + + /** + * Append the migration data to the schema dump. + * + * @param string $path + * @return void + */ + protected function appendMigrationData(string $path) + { + with($process = $this->makeProcess( + $this->baseCommand().' ".dump \''.$this->migrationTable.'\'"' + ))->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) { + return preg_match('/^\s*(--|INSERT\s)/iu', $line) === 1 && + strlen($line) > 0; + })->all(); + + $this->files->append($path, implode(PHP_EOL, $migrations).PHP_EOL); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $process = $this->makeProcess($this->baseCommand().' < "${:LARAVEL_LOAD_PATH}"'); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base sqlite command arguments as a string. + * + * @return string + */ + protected function baseCommand() + { + return 'sqlite3 "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + return [ + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/src/Illuminate/Database/Seeder.php b/src/Illuminate/Database/Seeder.php index 2facfd7de225cfbd6732d75888d7a929ffd49b46..441fa27d6c16b7483da8e626b315a5aafccb2775 100755 --- a/src/Illuminate/Database/Seeder.php +++ b/src/Illuminate/Database/Seeder.php @@ -24,13 +24,14 @@ abstract class Seeder protected $command; /** - * Seed the given connection from the given path. + * Run the given seeder class. * * @param array|string $class * @param bool $silent + * @param array $parameters * @return $this */ - public function call($class, $silent = false) + public function call($class, $silent = false, array $parameters = []) { $classes = Arr::wrap($class); @@ -45,12 +46,12 @@ abstract class Seeder $startTime = microtime(true); - $seeder->__invoke(); + $seeder->__invoke($parameters); - $runTime = round(microtime(true) - $startTime, 2); + $runTime = number_format((microtime(true) - $startTime) * 1000, 2); if ($silent === false && isset($this->command)) { - $this->command->getOutput()->writeln("<info>Seeded:</info> {$name} ({$runTime} seconds)"); + $this->command->getOutput()->writeln("<info>Seeded:</info> {$name} ({$runTime}ms)"); } } @@ -58,14 +59,27 @@ abstract class Seeder } /** - * Silently seed the given connection from the given path. + * Run the given seeder class. * * @param array|string $class + * @param array $parameters * @return void */ - public function callSilent($class) + public function callWith($class, array $parameters = []) { - $this->call($class, true); + $this->call($class, false, $parameters); + } + + /** + * Silently run the given seeder class. + * + * @param array|string $class + * @param array $parameters + * @return void + */ + public function callSilent($class, array $parameters = []) + { + $this->call($class, true, $parameters); } /** @@ -120,18 +134,19 @@ abstract class Seeder /** * Run the database seeds. * + * @param array $parameters * @return mixed * * @throws \InvalidArgumentException */ - public function __invoke() + public function __invoke(array $parameters = []) { if (! method_exists($this, 'run')) { throw new InvalidArgumentException('Method [run] missing from '.get_class($this)); } return isset($this->container) - ? $this->container->call([$this, 'run']) - : $this->run(); + ? $this->container->call([$this, 'run'], $parameters) + : $this->run(...$parameters); } } diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 8a642572104fb65dff373aca27e9cad45630f556..d6a2b7ae95f3478d35b7afeb29db32f49d043c36 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -4,11 +4,14 @@ namespace Illuminate\Database; use Closure; use Doctrine\DBAL\Driver\PDOSqlsrv\Driver as DoctrineDriver; -use Exception; +use Doctrine\DBAL\Version; +use Illuminate\Database\PDO\SqlServerDriver; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; use Illuminate\Database\Schema\SqlServerBuilder; +use Illuminate\Filesystem\Filesystem; +use RuntimeException; use Throwable; class SqlServerConnection extends Connection @@ -20,13 +23,13 @@ class SqlServerConnection extends Connection * @param int $attempts * @return mixed * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function transaction(Closure $callback, $attempts = 1) { for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { - return parent::transaction($callback); + return parent::transaction($callback, $attempts); } $this->getPdo()->exec('BEGIN TRAN'); @@ -40,14 +43,10 @@ class SqlServerConnection extends Connection $this->getPdo()->exec('COMMIT TRAN'); } - // If we catch an exception, we will roll back so nothing gets messed + // If we catch an exception, we will rollback so nothing gets messed // up in the database. Then we'll re-throw the exception so it can // be handled how the developer sees fit for their applications. - catch (Exception $e) { - $this->getPdo()->exec('ROLLBACK TRAN'); - - throw $e; - } catch (Throwable $e) { + catch (Throwable $e) { $this->getPdo()->exec('ROLLBACK TRAN'); throw $e; @@ -91,6 +90,19 @@ class SqlServerConnection extends Connection return $this->withTablePrefix(new SchemaGrammar); } + /** + * Get the schema state for the connection. + * + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * + * @throws \RuntimeException + */ + public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + { + throw new RuntimeException('Schema dumping is not supported when using SQL Server.'); + } + /** * Get the default post processor instance. * @@ -104,10 +116,10 @@ class SqlServerConnection extends Connection /** * Get the Doctrine DBAL driver. * - * @return \Doctrine\DBAL\Driver\PDOSqlsrv\Driver + * @return \Doctrine\DBAL\Driver\PDOSqlsrv\Driver|\Illuminate\Database\PDO\SqlServerDriver */ protected function getDoctrineDriver() { - return new DoctrineDriver; + return class_exists(Version::class) ? new DoctrineDriver : new SqlServerDriver; } } diff --git a/src/Illuminate/Database/composer.json b/src/Illuminate/Database/composer.json index 2d658fb0f5e89552415556fc3633c7c8962ed239..12738d4aea24f8cf6c0f1b59abd36e11431ab067 100644 --- a/src/Illuminate/Database/composer.json +++ b/src/Illuminate/Database/composer.json @@ -15,11 +15,14 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "symfony/console": "^5.4" }, "autoload": { "psr-4": { @@ -28,17 +31,17 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.6).", + "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", - "illuminate/console": "Required to use the database commands (^6.0).", - "illuminate/events": "Required to use the observers with Eloquent (^6.0).", - "illuminate/filesystem": "Required to use the migrations (^6.0).", - "illuminate/pagination": "Required to paginate the result set (^6.0).", - "symfony/finder": "Required to use Eloquent model factories (^4.3.4)." + "illuminate/console": "Required to use the database commands (^8.0).", + "illuminate/events": "Required to use the observers with Eloquent (^8.0).", + "illuminate/filesystem": "Required to use the migrations (^8.0).", + "illuminate/pagination": "Required to paginate the result set (^8.0).", + "symfony/finder": "Required to use Eloquent model factories (^5.4)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Encryption/Encrypter.php b/src/Illuminate/Encryption/Encrypter.php index 662cd177e1f51b636bd10a3fa6e765face5a9f6b..9c2a7144657638eac6c1b368b2b863f5aadea21b 100755 --- a/src/Illuminate/Encryption/Encrypter.php +++ b/src/Illuminate/Encryption/Encrypter.php @@ -5,9 +5,10 @@ namespace Illuminate\Encryption; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\Encrypter as EncrypterContract; use Illuminate\Contracts\Encryption\EncryptException; +use Illuminate\Contracts\Encryption\StringEncrypter; use RuntimeException; -class Encrypter implements EncrypterContract +class Encrypter implements EncrypterContract, StringEncrypter { /** * The encryption key. @@ -23,6 +24,18 @@ class Encrypter implements EncrypterContract */ protected $cipher; + /** + * The supported cipher algorithms and their properties. + * + * @var array + */ + private static $supportedCiphers = [ + 'aes-128-cbc' => ['size' => 16, 'aead' => false], + 'aes-256-cbc' => ['size' => 32, 'aead' => false], + 'aes-128-gcm' => ['size' => 16, 'aead' => true], + 'aes-256-gcm' => ['size' => 32, 'aead' => true], + ]; + /** * Create a new encrypter instance. * @@ -32,16 +45,18 @@ class Encrypter implements EncrypterContract * * @throws \RuntimeException */ - public function __construct($key, $cipher = 'AES-128-CBC') + public function __construct($key, $cipher = 'aes-128-cbc') { $key = (string) $key; - if (static::supported($key, $cipher)) { - $this->key = $key; - $this->cipher = $cipher; - } else { - throw new RuntimeException('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'); + if (! static::supported($key, $cipher)) { + $ciphers = implode(', ', array_keys(self::$supportedCiphers)); + + throw new RuntimeException("Unsupported cipher or incorrect key length. Supported ciphers are: {$ciphers}."); } + + $this->key = $key; + $this->cipher = $cipher; } /** @@ -53,10 +68,11 @@ class Encrypter implements EncrypterContract */ public static function supported($key, $cipher) { - $length = mb_strlen($key, '8bit'); + if (! isset(self::$supportedCiphers[strtolower($cipher)])) { + return false; + } - return ($cipher === 'AES-128-CBC' && $length === 16) || - ($cipher === 'AES-256-CBC' && $length === 32); + return mb_strlen($key, '8bit') === self::$supportedCiphers[strtolower($cipher)]['size']; } /** @@ -67,7 +83,7 @@ class Encrypter implements EncrypterContract */ public static function generateKey($cipher) { - return random_bytes($cipher === 'AES-128-CBC' ? 16 : 32); + return random_bytes(self::$supportedCiphers[strtolower($cipher)]['size'] ?? 32); } /** @@ -81,26 +97,32 @@ class Encrypter implements EncrypterContract */ public function encrypt($value, $serialize = true) { - $iv = random_bytes(openssl_cipher_iv_length($this->cipher)); - - // First we will encrypt the value using OpenSSL. After this is encrypted we - // will proceed to calculating a MAC for the encrypted value so that this - // value can be verified later as not having been changed by the users. - $value = \openssl_encrypt( - $serialize ? serialize($value) : $value, - $this->cipher, $this->key, 0, $iv - ); + $iv = random_bytes(openssl_cipher_iv_length(strtolower($this->cipher))); + + $tag = ''; + + $value = self::$supportedCiphers[strtolower($this->cipher)]['aead'] + ? \openssl_encrypt( + $serialize ? serialize($value) : $value, + strtolower($this->cipher), $this->key, 0, $iv, $tag + ) + : \openssl_encrypt( + $serialize ? serialize($value) : $value, + strtolower($this->cipher), $this->key, 0, $iv + ); if ($value === false) { throw new EncryptException('Could not encrypt the data.'); } - // Once we get the encrypted value we'll go ahead and base64_encode the input - // vector and create the MAC for the encrypted value so we can then verify - // its authenticity. Then, we'll JSON the data into the "payload" array. - $mac = $this->hash($iv = base64_encode($iv), $value); + $iv = base64_encode($iv); + $tag = base64_encode($tag); + + $mac = self::$supportedCiphers[strtolower($this->cipher)]['aead'] + ? '' // For AEAD-algoritms, the tag / MAC is returned by openssl_encrypt... + : $this->hash($iv, $value); - $json = json_encode(compact('iv', 'value', 'mac')); + $json = json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES); if (json_last_error() !== JSON_ERROR_NONE) { throw new EncryptException('Could not encrypt the data.'); @@ -137,11 +159,15 @@ class Encrypter implements EncrypterContract $iv = base64_decode($payload['iv']); + $this->ensureTagIsValid( + $tag = empty($payload['tag']) ? null : base64_decode($payload['tag']) + ); + // Here we will decrypt the value. If we are able to successfully decrypt it // we will then unserialize it and return it out to the caller. If we are // unable to decrypt this value we will throw out an exception message. $decrypted = \openssl_decrypt( - $payload['value'], $this->cipher, $this->key, 0, $iv + $payload['value'], strtolower($this->cipher), $this->key, 0, $iv, $tag ?? '' ); if ($decrypted === false) { @@ -195,7 +221,7 @@ class Encrypter implements EncrypterContract throw new DecryptException('The payload is invalid.'); } - if (! $this->validMac($payload)) { + if (! self::$supportedCiphers[strtolower($this->cipher)]['aead'] && ! $this->validMac($payload)) { throw new DecryptException('The MAC is invalid.'); } @@ -211,7 +237,7 @@ class Encrypter implements EncrypterContract protected function validPayload($payload) { return is_array($payload) && isset($payload['iv'], $payload['value'], $payload['mac']) && - strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length($this->cipher); + strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length(strtolower($this->cipher)); } /** @@ -222,25 +248,26 @@ class Encrypter implements EncrypterContract */ protected function validMac(array $payload) { - $calculated = $this->calculateMac($payload, $bytes = random_bytes(16)); - return hash_equals( - hash_hmac('sha256', $payload['mac'], $bytes, true), $calculated + $this->hash($payload['iv'], $payload['value']), $payload['mac'] ); } /** - * Calculate the hash of the given payload. + * Ensure the given tag is a valid tag given the selected cipher. * - * @param array $payload - * @param string $bytes - * @return string + * @param string $tag + * @return void */ - protected function calculateMac($payload, $bytes) + protected function ensureTagIsValid($tag) { - return hash_hmac( - 'sha256', $this->hash($payload['iv'], $payload['value']), $bytes, true - ); + if (self::$supportedCiphers[strtolower($this->cipher)]['aead'] && strlen($tag) !== 16) { + throw new DecryptException('Could not decrypt the data.'); + } + + if (! self::$supportedCiphers[strtolower($this->cipher)]['aead'] && is_string($tag)) { + throw new DecryptException('Unable to use tag because the cipher algorithm does not support AEAD.'); + } } /** diff --git a/src/Illuminate/Encryption/EncryptionServiceProvider.php b/src/Illuminate/Encryption/EncryptionServiceProvider.php index cd590f12dbb36738d6f0ea5221b34752b6442e08..4ef42ba4c32cb8201d4e91c99b2a0711b2b8be83 100755 --- a/src/Illuminate/Encryption/EncryptionServiceProvider.php +++ b/src/Illuminate/Encryption/EncryptionServiceProvider.php @@ -4,8 +4,8 @@ namespace Illuminate\Encryption; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; -use Opis\Closure\SerializableClosure; -use RuntimeException; +use Laravel\SerializableClosure\SerializableClosure; +use Opis\Closure\SerializableClosure as OpisSerializableClosure; class EncryptionServiceProvider extends ServiceProvider { @@ -18,6 +18,7 @@ class EncryptionServiceProvider extends ServiceProvider { $this->registerEncrypter(); $this->registerOpisSecurityKey(); + $this->registerSerializableClosureSecurityKey(); } /** @@ -38,8 +39,28 @@ class EncryptionServiceProvider extends ServiceProvider * Configure Opis Closure signing for security. * * @return void + * + * @deprecated Will be removed in a future Laravel version. */ protected function registerOpisSecurityKey() + { + if (\PHP_VERSION_ID < 80100) { + $config = $this->app->make('config')->get('app'); + + if (! class_exists(OpisSerializableClosure::class) || empty($config['key'])) { + return; + } + + OpisSerializableClosure::setSecretKey($this->parseKey($config)); + } + } + + /** + * Configure Serializable Closure signing for security. + * + * @return void + */ + protected function registerSerializableClosureSecurityKey() { $config = $this->app->make('config')->get('app'); @@ -71,15 +92,13 @@ class EncryptionServiceProvider extends ServiceProvider * @param array $config * @return string * - * @throws \RuntimeException + * @throws \Illuminate\Encryption\MissingAppKeyException */ protected function key(array $config) { return tap($config['key'], function ($key) { if (empty($key)) { - throw new RuntimeException( - 'No application encryption key has been specified.' - ); + throw new MissingAppKeyException; } }); } diff --git a/src/Illuminate/Encryption/MissingAppKeyException.php b/src/Illuminate/Encryption/MissingAppKeyException.php new file mode 100644 index 0000000000000000000000000000000000000000..d8ffcd184b51c6d489fe0cdcbd11ba0e639b08de --- /dev/null +++ b/src/Illuminate/Encryption/MissingAppKeyException.php @@ -0,0 +1,19 @@ +<?php + +namespace Illuminate\Encryption; + +use RuntimeException; + +class MissingAppKeyException extends RuntimeException +{ + /** + * Create a new exception instance. + * + * @param string $message + * @return void + */ + public function __construct($message = 'No application encryption key has been specified.') + { + parent::__construct($message); + } +} diff --git a/src/Illuminate/Encryption/composer.json b/src/Illuminate/Encryption/composer.json index cc0b3e3494f0ede7ef3283ac43c9601f79176160..f90637f00a70e6976a76e93b02fca6e35ff65793 100644 --- a/src/Illuminate/Encryption/composer.json +++ b/src/Illuminate/Encryption/composer.json @@ -14,12 +14,12 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "illuminate/contracts": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -28,7 +28,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Events/CallQueuedListener.php b/src/Illuminate/Events/CallQueuedListener.php index c7fc821de584f43977f5e97dff5941c9ae5e2101..6a39008520c854d6d0cc2e71d73f12aa2bfb57e3 100644 --- a/src/Illuminate/Events/CallQueuedListener.php +++ b/src/Illuminate/Events/CallQueuedListener.php @@ -2,6 +2,7 @@ namespace Illuminate\Events; +use Illuminate\Bus\Queueable; use Illuminate\Container\Container; use Illuminate\Contracts\Queue\Job; use Illuminate\Contracts\Queue\ShouldQueue; @@ -9,7 +10,7 @@ use Illuminate\Queue\InteractsWithQueue; class CallQueuedListener implements ShouldQueue { - use InteractsWithQueue; + use InteractsWithQueue, Queueable; /** * The listener class name. @@ -40,18 +41,25 @@ class CallQueuedListener implements ShouldQueue public $tries; /** - * The number of seconds to wait before retrying the job. + * The maximum number of exceptions allowed, regardless of attempts. * * @var int */ - public $retryAfter; + public $maxExceptions; + + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. + * + * @var int + */ + public $backoff; /** * The timestamp indicating when the job should timeout. * * @var int */ - public $timeoutAt; + public $retryUntil; /** * The number of seconds the job can run before timing out. @@ -60,6 +68,13 @@ class CallQueuedListener implements ShouldQueue */ public $timeout; + /** + * Indicates if the job should be encrypted. + * + * @var bool + */ + public $shouldBeEncrypted = false; + /** * Create a new job instance. * @@ -113,7 +128,7 @@ class CallQueuedListener implements ShouldQueue * * The event instance and the exception will be passed. * - * @param \Exception $e + * @param \Throwable $e * @return void */ public function failed($e) diff --git a/src/Illuminate/Events/Dispatcher.php b/src/Illuminate/Events/Dispatcher.php index eb25f5c6f5a44dba4b5547162fba8f4289f7be49..5972a8384947f1eef46d43c17f4fadcf12d5b8bc 100755 --- a/src/Illuminate/Events/Dispatcher.php +++ b/src/Illuminate/Events/Dispatcher.php @@ -2,21 +2,24 @@ namespace Illuminate\Events; +use Closure; use Exception; use Illuminate\Container\Container; use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Container\Container as ContainerContract; use Illuminate\Contracts\Events\Dispatcher as DispatcherContract; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\ReflectsClosures; use ReflectionClass; class Dispatcher implements DispatcherContract { - use Macroable; + use Macroable, ReflectsClosures; /** * The IoC container instance. @@ -67,12 +70,26 @@ class Dispatcher implements DispatcherContract /** * Register an event listener with the dispatcher. * - * @param string|array $events - * @param \Closure|string $listener + * @param \Closure|string|array $events + * @param \Closure|string|array|null $listener * @return void */ - public function listen($events, $listener) - { + public function listen($events, $listener = null) + { + if ($events instanceof Closure) { + return collect($this->firstClosureParameterTypes($events)) + ->each(function ($event) use ($events) { + $this->listen($event, $events); + }); + } elseif ($events instanceof QueuedClosure) { + return collect($this->firstClosureParameterTypes($events->closure)) + ->each(function ($event) use ($events) { + $this->listen($event, $events->resolve()); + }); + } elseif ($listener instanceof QueuedClosure) { + $listener = $listener->resolve(); + } + foreach ((array) $events as $event) { if (Str::contains($event, '*')) { $this->setupWildcardListen($event, $listener); @@ -161,7 +178,21 @@ class Dispatcher implements DispatcherContract { $subscriber = $this->resolveSubscriber($subscriber); - $subscriber->subscribe($this); + $events = $subscriber->subscribe($this); + + if (is_array($events)) { + foreach ($events as $event => $listeners) { + foreach (Arr::wrap($listeners) as $listener) { + if (is_string($listener) && method_exists($subscriber, $listener)) { + $this->listen($event, [get_class($subscriber), $listener]); + + continue; + } + + $this->listen($event, $listener); + } + } + } } /** @@ -267,7 +298,7 @@ class Dispatcher implements DispatcherContract } /** - * Check if event should be broadcasted by condition. + * Check if the event should be broadcasted by the condition. * * @param mixed $event * @return bool @@ -351,7 +382,7 @@ class Dispatcher implements DispatcherContract /** * Register an event listener with the dispatcher. * - * @param \Closure|string $listener + * @param \Closure|string|array $listener * @param bool $wildcard * @return \Closure */ @@ -361,6 +392,10 @@ class Dispatcher implements DispatcherContract return $this->createClassListener($listener, $wildcard); } + if (is_array($listener) && isset($listener[0]) && is_string($listener[0])) { + return $this->createClassListener($listener, $wildcard); + } + return function ($event, $payload) use ($listener, $wildcard) { if ($wildcard) { return $listener($event, $payload); @@ -393,18 +428,28 @@ class Dispatcher implements DispatcherContract /** * Create the class based event callable. * - * @param string $listener + * @param array|string $listener * @return callable */ protected function createClassCallable($listener) { - [$class, $method] = $this->parseClassCallable($listener); + [$class, $method] = is_array($listener) + ? $listener + : $this->parseClassCallable($listener); + + if (! method_exists($class, $method)) { + $method = '__invoke'; + } if ($this->handlerShouldBeQueued($class)) { return $this->createQueuedHandlerCallable($class, $method); } - return [$this->container->make($class), $method]; + $listener = $this->container->make($class); + + return $this->handlerShouldBeDispatchedAfterDatabaseTransactions($listener) + ? $this->createCallbackForListenerRunningAfterCommits($listener, $method) + : [$listener, $method]; } /** @@ -455,6 +500,37 @@ class Dispatcher implements DispatcherContract }; } + /** + * Determine if the given event handler should be dispatched after all database transactions have committed. + * + * @param object|mixed $listener + * @return bool + */ + protected function handlerShouldBeDispatchedAfterDatabaseTransactions($listener) + { + return ($listener->afterCommit ?? null) && $this->container->bound('db.transactions'); + } + + /** + * Create a callable for dispatching a listener after database transactions. + * + * @param mixed $listener + * @param string $method + * @return \Closure + */ + protected function createCallbackForListenerRunningAfterCommits($listener, $method) + { + return function () use ($method, $listener) { + $payload = func_get_args(); + + $this->container->make('db.transactions')->addCallback( + function () use ($listener, $method, $payload) { + $listener->$method(...$payload); + } + ); + }; + } + /** * Determine if the event handler wants to be queued. * @@ -485,11 +561,13 @@ class Dispatcher implements DispatcherContract { [$listener, $job] = $this->createListenerAndJob($class, $method, $arguments); - $connection = $this->resolveQueue()->connection( - $listener->connection ?? null - ); + $connection = $this->resolveQueue()->connection(method_exists($listener, 'viaConnection') + ? $listener->viaConnection() + : $listener->connection ?? null); - $queue = $listener->queue ?? null; + $queue = method_exists($listener, 'viaQueue') + ? $listener->viaQueue() + : $listener->queue ?? null; isset($listener->delay) ? $connection->laterOn($queue, $listener->delay, $job) @@ -523,11 +601,18 @@ class Dispatcher implements DispatcherContract protected function propagateListenerOptions($listener, $job) { return tap($job, function ($job) use ($listener) { - $job->tries = $listener->tries ?? null; - $job->retryAfter = $listener->retryAfter ?? null; + $job->afterCommit = property_exists($listener, 'afterCommit') ? $listener->afterCommit : null; + $job->backoff = method_exists($listener, 'backoff') ? $listener->backoff() : ($listener->backoff ?? null); + $job->maxExceptions = $listener->maxExceptions ?? null; + $job->retryUntil = method_exists($listener, 'retryUntil') ? $listener->retryUntil() : null; + $job->shouldBeEncrypted = $listener instanceof ShouldBeEncrypted; $job->timeout = $listener->timeout ?? null; - $job->timeoutAt = method_exists($listener, 'retryUntil') - ? $listener->retryUntil() : null; + $job->tries = $listener->tries ?? null; + + $job->through(array_merge( + method_exists($listener, 'middleware') ? $listener->middleware() : [], + $listener->middleware ?? [] + )); }); } @@ -569,7 +654,7 @@ class Dispatcher implements DispatcherContract /** * Get the queue implementation from the resolver. * - * @return \Illuminate\Contracts\Queue\Factory + * @return \Illuminate\Contracts\Queue\Queue */ protected function resolveQueue() { diff --git a/src/Illuminate/Events/InvokeQueuedClosure.php b/src/Illuminate/Events/InvokeQueuedClosure.php new file mode 100644 index 0000000000000000000000000000000000000000..bc68b19d6443fb357d8cdadc602fa3f47cad92b2 --- /dev/null +++ b/src/Illuminate/Events/InvokeQueuedClosure.php @@ -0,0 +1,34 @@ +<?php + +namespace Illuminate\Events; + +class InvokeQueuedClosure +{ + /** + * Handle the event. + * + * @param \Laravel\SerializableClosure\SerializableClosure $closure + * @param array $arguments + * @return void + */ + public function handle($closure, array $arguments) + { + call_user_func($closure->getClosure(), ...$arguments); + } + + /** + * Handle a job failure. + * + * @param \Laravel\SerializableClosure\SerializableClosure $closure + * @param array $arguments + * @param array $catchCallbacks + * @param \Throwable $exception + * @return void + */ + public function failed($closure, array $arguments, array $catchCallbacks, $exception) + { + $arguments[] = $exception; + + collect($catchCallbacks)->each->__invoke(...$arguments); + } +} diff --git a/src/Illuminate/Events/NullDispatcher.php b/src/Illuminate/Events/NullDispatcher.php index 793ef1e19ccd8e73f5ea8ca94bb745702e19a052..5c539d53a3618eb5af8232afb35a028730a447bc 100644 --- a/src/Illuminate/Events/NullDispatcher.php +++ b/src/Illuminate/Events/NullDispatcher.php @@ -12,7 +12,7 @@ class NullDispatcher implements DispatcherContract /** * The underlying event dispatcher instance. * - * @var \Illuminate\Contracts\Bus\Dispatcher + * @var \Illuminate\Contracts\Events\Dispatcher */ protected $dispatcher; @@ -37,6 +37,7 @@ class NullDispatcher implements DispatcherContract */ public function dispatch($event, $payload = [], $halt = false) { + // } /** @@ -48,6 +49,7 @@ class NullDispatcher implements DispatcherContract */ public function push($event, $payload = []) { + // } /** @@ -59,16 +61,17 @@ class NullDispatcher implements DispatcherContract */ public function until($event, $payload = []) { + // } /** * Register an event listener with the dispatcher. * - * @param string|array $events - * @param \Closure|string $listener + * @param \Closure|string|array $events + * @param \Closure|string|array|null $listener * @return void */ - public function listen($events, $listener) + public function listen($events, $listener = null) { $this->dispatcher->listen($events, $listener); } diff --git a/src/Illuminate/Events/QueuedClosure.php b/src/Illuminate/Events/QueuedClosure.php new file mode 100644 index 0000000000000000000000000000000000000000..82590598447a62c91f928b5fab79be50cfd12108 --- /dev/null +++ b/src/Illuminate/Events/QueuedClosure.php @@ -0,0 +1,125 @@ +<?php + +namespace Illuminate\Events; + +use Closure; +use Illuminate\Queue\SerializableClosureFactory; + +class QueuedClosure +{ + /** + * The underlying Closure. + * + * @var \Closure + */ + public $closure; + + /** + * The name of the connection the job should be sent to. + * + * @var string|null + */ + public $connection; + + /** + * The name of the queue the job should be sent to. + * + * @var string|null + */ + public $queue; + + /** + * The number of seconds before the job should be made available. + * + * @var \DateTimeInterface|\DateInterval|int|null + */ + public $delay; + + /** + * All of the "catch" callbacks for the queued closure. + * + * @var array + */ + public $catchCallbacks = []; + + /** + * Create a new queued closure event listener resolver. + * + * @param \Closure $closure + * @return void + */ + public function __construct(Closure $closure) + { + $this->closure = $closure; + } + + /** + * Set the desired connection for the job. + * + * @param string|null $connection + * @return $this + */ + public function onConnection($connection) + { + $this->connection = $connection; + + return $this; + } + + /** + * Set the desired queue for the job. + * + * @param string|null $queue + * @return $this + */ + public function onQueue($queue) + { + $this->queue = $queue; + + return $this; + } + + /** + * Set the desired delay for the job. + * + * @param \DateTimeInterface|\DateInterval|int|null $delay + * @return $this + */ + public function delay($delay) + { + $this->delay = $delay; + + return $this; + } + + /** + * Specify a callback that should be invoked if the queued listener job fails. + * + * @param \Closure $closure + * @return $this + */ + public function catch(Closure $closure) + { + $this->catchCallbacks[] = $closure; + + return $this; + } + + /** + * Resolve the actual event listener callback. + * + * @return \Closure + */ + public function resolve() + { + return function (...$arguments) { + dispatch(new CallQueuedListener(InvokeQueuedClosure::class, 'handle', [ + 'closure' => SerializableClosureFactory::make($this->closure), + 'arguments' => $arguments, + 'catch' => collect($this->catchCallbacks)->map(function ($callback) { + return SerializableClosureFactory::make($callback); + })->all(), + ]))->onConnection($this->connection)->onQueue($this->queue)->delay($this->delay); + }; + } +} diff --git a/src/Illuminate/Events/composer.json b/src/Illuminate/Events/composer.json index 9dd481dc0eb163c04b12fd690eee583461c96cba..b77ba2c896854b6b737366d3c5e129bb4210f2f0 100755 --- a/src/Illuminate/Events/composer.json +++ b/src/Illuminate/Events/composer.json @@ -14,19 +14,25 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/bus": "^8.0", + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { "Illuminate\\Events\\": "" - } + }, + "files": [ + "functions.php" + ] }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Events/functions.php b/src/Illuminate/Events/functions.php new file mode 100644 index 0000000000000000000000000000000000000000..df1b0febf23dce052a0b86072bc3ac98b3891ecb --- /dev/null +++ b/src/Illuminate/Events/functions.php @@ -0,0 +1,18 @@ +<?php + +namespace Illuminate\Events; + +use Closure; + +if (! function_exists('Illuminate\Events\queueable')) { + /** + * Create a new queued Closure event listener. + * + * @param \Closure $closure + * @return \Illuminate\Events\QueuedClosure + */ + function queueable(Closure $closure) + { + return new QueuedClosure($closure); + } +} diff --git a/src/Illuminate/Filesystem/Filesystem.php b/src/Illuminate/Filesystem/Filesystem.php index b7459f776255d11be7d8b6170ed9a4a649ff99a8..8b98ae87acdc73730c49efa706e195a8960eeba1 100644 --- a/src/Illuminate/Filesystem/Filesystem.php +++ b/src/Illuminate/Filesystem/Filesystem.php @@ -5,8 +5,13 @@ namespace Illuminate\Filesystem; use ErrorException; use FilesystemIterator; use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Support\LazyCollection; use Illuminate\Support\Traits\Macroable; +use RuntimeException; +use SplFileObject; +use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; use Symfony\Component\Finder\Finder; +use Symfony\Component\Mime\MimeTypes; class Filesystem { @@ -49,7 +54,7 @@ class Filesystem return $lock ? $this->sharedGet($path) : file_get_contents($path); } - throw new FileNotFoundException("File does not exist at path {$path}"); + throw new FileNotFoundException("File does not exist at path {$path}."); } /** @@ -85,28 +90,77 @@ class Filesystem * Get the returned value of a file. * * @param string $path + * @param array $data * @return mixed * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ - public function getRequire($path) + public function getRequire($path, array $data = []) { if ($this->isFile($path)) { - return require $path; + $__path = $path; + $__data = $data; + + return (static function () use ($__path, $__data) { + extract($__data, EXTR_SKIP); + + return require $__path; + })(); } - throw new FileNotFoundException("File does not exist at path {$path}"); + throw new FileNotFoundException("File does not exist at path {$path}."); } /** * Require the given file once. * - * @param string $file + * @param string $path + * @param array $data * @return mixed + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ - public function requireOnce($file) + public function requireOnce($path, array $data = []) { - require_once $file; + if ($this->isFile($path)) { + $__path = $path; + $__data = $data; + + return (static function () use ($__path, $__data) { + extract($__data, EXTR_SKIP); + + return require_once $__path; + })(); + } + + throw new FileNotFoundException("File does not exist at path {$path}."); + } + + /** + * Get the contents of a file one line at a time. + * + * @param string $path + * @return \Illuminate\Support\LazyCollection + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + public function lines($path) + { + if (! $this->isFile($path)) { + throw new FileNotFoundException( + "File does not exist at path {$path}." + ); + } + + return LazyCollection::make(function () use ($path) { + $file = new SplFileObject($path); + + $file->setFlags(SplFileObject::DROP_NEW_LINE); + + while (! $file->eof()) { + yield $file->fgets(); + } + }); } /** @@ -157,6 +211,19 @@ class Filesystem rename($tempPath, $path); } + /** + * Replace a given string within a given file. + * + * @param array|string $search + * @param array|string $replace + * @param string $path + * @return void + */ + public function replaceInFile($search, $replace, $path) + { + file_put_contents($path, str_replace($search, $replace, file_get_contents($path))); + } + /** * Prepend to a file. * @@ -215,7 +282,9 @@ class Filesystem foreach ($paths as $path) { try { - if (! @unlink($path)) { + if (@unlink($path)) { + clearstatcache(false, $path); + } else { $success = false; } } catch (ErrorException $e) { @@ -268,6 +337,28 @@ class Filesystem exec("mklink /{$mode} ".escapeshellarg($link).' '.escapeshellarg($target)); } + /** + * Create a relative symlink to the target file or directory. + * + * @param string $target + * @param string $link + * @return void + * + * @throws \RuntimeException + */ + public function relativeLink($target, $link) + { + if (! class_exists(SymfonyFilesystem::class)) { + throw new RuntimeException( + 'To enable support for relative links, please install the symfony/filesystem package.' + ); + } + + $relativeTarget = (new SymfonyFilesystem)->makePathRelative($target, dirname($link)); + + $this->link($relativeTarget, $link); + } + /** * Extract the file name from a file path. * @@ -312,6 +403,25 @@ class Filesystem return pathinfo($path, PATHINFO_EXTENSION); } + /** + * Guess the file extension from the mime-type of a given file. + * + * @param string $path + * @return string|null + * + * @throws \RuntimeException + */ + public function guessExtension($path) + { + if (! class_exists(MimeTypes::class)) { + throw new RuntimeException( + 'To enable support for guessing extensions, please install the symfony/mime package.' + ); + } + + return (new MimeTypes)->getExtensions($this->mimeType($path))[0] ?? null; + } + /** * Get the file type of a given file. * @@ -528,9 +638,7 @@ class Filesystem // If the destination directory does not actually exist, we will go ahead and // create it recursively, which just gets the destination prepared to copy // the files over. Once we make the directory we'll proceed the copying. - if (! $this->isDirectory($destination)) { - $this->makeDirectory($destination, 0777, true); - } + $this->ensureDirectoryExists($destination, 0777); $items = new FilesystemIterator($directory, $options); diff --git a/src/Illuminate/Filesystem/FilesystemAdapter.php b/src/Illuminate/Filesystem/FilesystemAdapter.php index e4b917c0d405573dcfee8b2694ac32c2a113a099..a9fd524740b708f8c1acff42a1d5bea2224bca08 100644 --- a/src/Illuminate/Filesystem/FilesystemAdapter.php +++ b/src/Illuminate/Filesystem/FilesystemAdapter.php @@ -2,6 +2,7 @@ namespace Illuminate\Filesystem; +use Closure; use Illuminate\Contracts\Filesystem\Cloud as CloudFilesystemContract; use Illuminate\Contracts\Filesystem\FileExistsException as ContractFileExistsException; use Illuminate\Contracts\Filesystem\FileNotFoundException as ContractFileNotFoundException; @@ -11,6 +12,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; use League\Flysystem\Adapter\Ftp; use League\Flysystem\Adapter\Local as LocalAdapter; @@ -20,8 +22,10 @@ use League\Flysystem\Cached\CachedAdapter; use League\Flysystem\FileExistsException; use League\Flysystem\FileNotFoundException; use League\Flysystem\FilesystemInterface; +use League\Flysystem\Sftp\SftpAdapter as Sftp; use PHPUnit\Framework\Assert as PHPUnit; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -30,6 +34,10 @@ use Symfony\Component\HttpFoundation\StreamedResponse; */ class FilesystemAdapter implements CloudFilesystemContract { + use Macroable { + __call as macroCall; + } + /** * The Flysystem filesystem implementation. * @@ -37,6 +45,13 @@ class FilesystemAdapter implements CloudFilesystemContract */ protected $driver; + /** + * The temporary URL builder callback. + * + * @var \Closure|null + */ + protected $temporaryUrlCallback; + /** * Create a new filesystem adapter instance. * @@ -52,16 +67,29 @@ class FilesystemAdapter implements CloudFilesystemContract * Assert that the given file exists. * * @param string|array $path + * @param string|null $content * @return $this */ - public function assertExists($path) + public function assertExists($path, $content = null) { + clearstatcache(); + $paths = Arr::wrap($path); foreach ($paths as $path) { PHPUnit::assertTrue( $this->exists($path), "Unable to find a file at path [{$path}]." ); + + if (! is_null($content)) { + $actual = $this->get($path); + + PHPUnit::assertSame( + $content, + $actual, + "File [{$path}] was found, but content [{$actual}] does not match [{$content}]." + ); + } } return $this; @@ -75,6 +103,8 @@ class FilesystemAdapter implements CloudFilesystemContract */ public function assertMissing($path) { + clearstatcache(); + $paths = Arr::wrap($path); foreach ($paths as $path) { @@ -116,7 +146,13 @@ class FilesystemAdapter implements CloudFilesystemContract */ public function path($path) { - return $this->driver->getAdapter()->getPathPrefix().$path; + $adapter = $this->driver->getAdapter(); + + if ($adapter instanceof CachedAdapter) { + $adapter = $adapter->getAdapter(); + } + + return $adapter->getPathPrefix().$path; } /** @@ -198,7 +234,7 @@ class FilesystemAdapter implements CloudFilesystemContract * Write the contents of a file. * * @param string $path - * @param string|resource $contents + * @param \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource $contents * @param mixed $options * @return bool */ @@ -432,7 +468,7 @@ class FilesystemAdapter implements CloudFilesystemContract return $this->driver->getUrl($path); } elseif ($adapter instanceof AwsS3Adapter) { return $this->getAwsUrl($adapter, $path); - } elseif ($adapter instanceof Ftp) { + } elseif ($adapter instanceof Ftp || $adapter instanceof Sftp) { return $this->getFtpUrl($path); } elseif ($adapter instanceof LocalAdapter) { return $this->getLocalUrl($path); @@ -550,11 +586,19 @@ class FilesystemAdapter implements CloudFilesystemContract if (method_exists($adapter, 'getTemporaryUrl')) { return $adapter->getTemporaryUrl($path, $expiration, $options); - } elseif ($adapter instanceof AwsS3Adapter) { + } + + if ($this->temporaryUrlCallback) { + return $this->temporaryUrlCallback->bindTo($this, static::class)( + $path, $expiration, $options + ); + } + + if ($adapter instanceof AwsS3Adapter) { return $this->getAwsTemporaryUrl($adapter, $path, $expiration, $options); - } else { - throw new RuntimeException('This driver does not support creating temporary URLs.'); } + + throw new RuntimeException('This driver does not support creating temporary URLs.'); } /** @@ -575,9 +619,18 @@ class FilesystemAdapter implements CloudFilesystemContract 'Key' => $adapter->getPathPrefix().$path, ], $options)); - return (string) $client->createPresignedRequest( + $uri = $client->createPresignedRequest( $command, $expiration )->getUri(); + + // If an explicit base URL has been set on the disk configuration then we will use + // it as the base URL instead of the default path. This allows the developer to + // have full control over the base path for this filesystem's generated URLs. + if (! is_null($url = $this->driver->getConfig()->get('temporary_url'))) { + $uri = $this->replaceBaseUrl($uri, $url); + } + + return (string) $uri; } /** @@ -592,6 +645,23 @@ class FilesystemAdapter implements CloudFilesystemContract return rtrim($url, '/').'/'.ltrim($path, '/'); } + /** + * Replace the scheme, host and port of the given UriInterface with values from the given URL. + * + * @param \Psr\Http\Message\UriInterface $uri + * @param string $url + * @return \Psr\Http\Message\UriInterface + */ + protected function replaceBaseUrl($uri, $url) + { + $parsed = parse_url($url); + + return $uri + ->withScheme($parsed['scheme']) + ->withHost($parsed['host']) + ->withPort($parsed['port'] ?? null); + } + /** * Get an array of all files in a directory. * @@ -601,7 +671,7 @@ class FilesystemAdapter implements CloudFilesystemContract */ public function files($directory = null, $recursive = false) { - $contents = $this->driver->listContents($directory, $recursive); + $contents = $this->driver->listContents($directory ?? '', $recursive); return $this->filterContentsByType($contents, 'file'); } @@ -626,7 +696,7 @@ class FilesystemAdapter implements CloudFilesystemContract */ public function directories($directory = null, $recursive = false) { - $contents = $this->driver->listContents($directory, $recursive); + $contents = $this->driver->listContents($directory ?? '', $recursive); return $this->filterContentsByType($contents, 'dir'); } @@ -725,7 +795,18 @@ class FilesystemAdapter implements CloudFilesystemContract return AdapterInterface::VISIBILITY_PRIVATE; } - throw new InvalidArgumentException("Unknown visibility: {$visibility}"); + throw new InvalidArgumentException("Unknown visibility: {$visibility}."); + } + + /** + * Define a custom temporary URL builder callback. + * + * @param \Closure $callback + * @return void + */ + public function buildTemporaryUrlsUsing(Closure $callback) + { + $this->temporaryUrlCallback = $callback; } /** @@ -739,6 +820,10 @@ class FilesystemAdapter implements CloudFilesystemContract */ public function __call($method, array $parameters) { - return $this->driver->{$method}(...array_values($parameters)); + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return $this->driver->{$method}(...$parameters); } } diff --git a/src/Illuminate/Filesystem/FilesystemManager.php b/src/Illuminate/Filesystem/FilesystemManager.php index 6003ac6b963428bfc9385c49ab0a4605988b56b0..684c487382f425c64647989f307ca32883195404 100644 --- a/src/Illuminate/Filesystem/FilesystemManager.php +++ b/src/Illuminate/Filesystem/FilesystemManager.php @@ -90,6 +90,20 @@ class FilesystemManager implements FactoryContract return $this->disks[$name] = $this->get($name); } + /** + * Build an on-demand disk. + * + * @param string|array $config + * @return \Illuminate\Contracts\Filesystem\Filesystem + */ + public function build($config) + { + return $this->resolve('ondemand', is_array($config) ? $config : [ + 'driver' => 'local', + 'root' => $config, + ]); + } + /** * Attempt to get the disk from the local cache. * @@ -105,13 +119,14 @@ class FilesystemManager implements FactoryContract * Resolve the given disk. * * @param string $name + * @param array|null $config * @return \Illuminate\Contracts\Filesystem\Filesystem * * @throws \InvalidArgumentException */ - protected function resolve($name) + protected function resolve($name, $config = null) { - $config = $this->getConfig($name); + $config = $config ?? $this->getConfig($name); if (empty($config['driver'])) { throw new InvalidArgumentException("Disk [{$name}] does not have a configured driver."); @@ -125,11 +140,11 @@ class FilesystemManager implements FactoryContract $driverMethod = 'create'.ucfirst($name).'Driver'; - if (method_exists($this, $driverMethod)) { - return $this->{$driverMethod}($config); - } else { + if (! method_exists($this, $driverMethod)) { throw new InvalidArgumentException("Driver [{$name}] is not supported."); } + + return $this->{$driverMethod}($config); } /** @@ -243,7 +258,7 @@ class FilesystemManager implements FactoryContract { $cache = Arr::pull($config, 'cache'); - $config = Arr::only($config, ['visibility', 'disable_asserts', 'url']); + $config = Arr::only($config, ['visibility', 'disable_asserts', 'url', 'temporary_url']); if ($cache) { $adapter = new CachedAdapter($adapter, $this->createCacheStore($cache)); @@ -326,7 +341,7 @@ class FilesystemManager implements FactoryContract */ public function getDefaultCloudDriver() { - return $this->app['config']['filesystems.cloud']; + return $this->app['config']['filesystems.cloud'] ?? 's3'; } /** @@ -344,6 +359,19 @@ class FilesystemManager implements FactoryContract return $this; } + /** + * Disconnect the given disk and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?? $this->getDefaultDriver(); + + unset($this->disks[$name]); + } + /** * Register a custom driver creator Closure. * @@ -358,6 +386,19 @@ class FilesystemManager implements FactoryContract return $this; } + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + return $this; + } + /** * Dynamically call the default driver instance. * diff --git a/src/Illuminate/Filesystem/FilesystemServiceProvider.php b/src/Illuminate/Filesystem/FilesystemServiceProvider.php index 693227056680b82764e55bbb21fda7f757c32796..ff348a2249219365d60ab76cb80fa068db9afe58 100644 --- a/src/Illuminate/Filesystem/FilesystemServiceProvider.php +++ b/src/Illuminate/Filesystem/FilesystemServiceProvider.php @@ -39,12 +39,12 @@ class FilesystemServiceProvider extends ServiceProvider { $this->registerManager(); - $this->app->singleton('filesystem.disk', function () { - return $this->app['filesystem']->disk($this->getDefaultDriver()); + $this->app->singleton('filesystem.disk', function ($app) { + return $app['filesystem']->disk($this->getDefaultDriver()); }); - $this->app->singleton('filesystem.cloud', function () { - return $this->app['filesystem']->disk($this->getCloudDriver()); + $this->app->singleton('filesystem.cloud', function ($app) { + return $app['filesystem']->disk($this->getCloudDriver()); }); } @@ -55,8 +55,8 @@ class FilesystemServiceProvider extends ServiceProvider */ protected function registerManager() { - $this->app->singleton('filesystem', function () { - return new FilesystemManager($this->app); + $this->app->singleton('filesystem', function ($app) { + return new FilesystemManager($app); }); } diff --git a/src/Illuminate/Filesystem/LockableFile.php b/src/Illuminate/Filesystem/LockableFile.php new file mode 100644 index 0000000000000000000000000000000000000000..58bd934f3234685a6138501a386b5a0e8a78a692 --- /dev/null +++ b/src/Illuminate/Filesystem/LockableFile.php @@ -0,0 +1,194 @@ +<?php + +namespace Illuminate\Filesystem; + +use Exception; +use Illuminate\Contracts\Filesystem\LockTimeoutException; + +class LockableFile +{ + /** + * The file resource. + * + * @var resource + */ + protected $handle; + + /** + * The file path. + * + * @var string + */ + protected $path; + + /** + * Indicates if the file is locked. + * + * @var bool + */ + protected $isLocked = false; + + /** + * Create a new File instance. + * + * @param string $path + * @param string $mode + * @return void + */ + public function __construct($path, $mode) + { + $this->path = $path; + + $this->ensureDirectoryExists($path); + $this->createResource($path, $mode); + } + + /** + * Create the file's directory if necessary. + * + * @param string $path + * @return void + */ + protected function ensureDirectoryExists($path) + { + if (! file_exists(dirname($path))) { + @mkdir(dirname($path), 0777, true); + } + } + + /** + * Create the file resource. + * + * @param string $path + * @param string $mode + * @return void + * + * @throws \Exception + */ + protected function createResource($path, $mode) + { + $this->handle = @fopen($path, $mode); + + if (! $this->handle) { + throw new Exception('Unable to create lockable file: '.$path.'. Please ensure you have permission to create files in this location.'); + } + } + + /** + * Read the file contents. + * + * @param int|null $length + * @return string + */ + public function read($length = null) + { + clearstatcache(true, $this->path); + + return fread($this->handle, $length ?? ($this->size() ?: 1)); + } + + /** + * Get the file size. + * + * @return int + */ + public function size() + { + return filesize($this->path); + } + + /** + * Write to the file. + * + * @param string $contents + * @return string + */ + public function write($contents) + { + fwrite($this->handle, $contents); + + fflush($this->handle); + + return $this; + } + + /** + * Truncate the file. + * + * @return $this + */ + public function truncate() + { + rewind($this->handle); + + ftruncate($this->handle, 0); + + return $this; + } + + /** + * Get a shared lock on the file. + * + * @param bool $block + * @return $this + * + * @throws \Illuminate\Contracts\Filesystem\LockTimeoutException + */ + public function getSharedLock($block = false) + { + if (! flock($this->handle, LOCK_SH | ($block ? 0 : LOCK_NB))) { + throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); + } + + $this->isLocked = true; + + return $this; + } + + /** + * Get an exclusive lock on the file. + * + * @param bool $block + * @return bool + * + * @throws \Illuminate\Contracts\Filesystem\LockTimeoutException + */ + public function getExclusiveLock($block = false) + { + if (! flock($this->handle, LOCK_EX | ($block ? 0 : LOCK_NB))) { + throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); + } + + $this->isLocked = true; + + return $this; + } + + /** + * Release the lock on the file. + * + * @return $this + */ + public function releaseLock() + { + flock($this->handle, LOCK_UN); + + $this->isLocked = false; + + return $this; + } + + /** + * Close the file. + * + * @return bool + */ + public function close() + { + if ($this->isLocked) { + $this->releaseLock(); + } + + return fclose($this->handle); + } +} diff --git a/src/Illuminate/Filesystem/composer.json b/src/Illuminate/Filesystem/composer.json index b9c23cea1f62565182313346041077b74d56522d..ca82b7a2732ab51a2bdf09beca65fc4ad11af38d 100644 --- a/src/Illuminate/Filesystem/composer.json +++ b/src/Illuminate/Filesystem/composer.json @@ -14,10 +14,12 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "symfony/finder": "^4.3.4" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "symfony/finder": "^5.4" }, "autoload": { "psr-4": { @@ -26,16 +28,19 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { "ext-ftp": "Required to use the Flysystem FTP driver.", + "illuminate/http": "Required for handling uploaded files (^7.0).", "league/flysystem": "Required to use the Flysystem local and FTP drivers (^1.1).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).", "league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).", "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).", - "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0)." + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^5.4).", + "symfony/mime": "Required to enable support for guessing extensions (^5.4)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Foundation/AliasLoader.php b/src/Illuminate/Foundation/AliasLoader.php index 63f38913df1c49927c6beb154f35f1bbd3c3c1c2..0f12b6c74421a62b48de660d7c27225121a4fed7 100755 --- a/src/Illuminate/Foundation/AliasLoader.php +++ b/src/Illuminate/Foundation/AliasLoader.php @@ -100,7 +100,7 @@ class AliasLoader */ protected function ensureFacadeExists($alias) { - if (file_exists($path = storage_path('framework/cache/facade-'.sha1($alias).'.php'))) { + if (is_file($path = storage_path('framework/cache/facade-'.sha1($alias).'.php'))) { return $path; } @@ -134,13 +134,13 @@ class AliasLoader /** * Add an alias to the loader. * - * @param string $class * @param string $alias + * @param string $class * @return void */ - public function alias($class, $alias) + public function alias($alias, $class) { - $this->aliases[$class] = $alias; + $this->aliases[$alias] = $class; } /** diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 35348d53f62bc0fbe5885b21e035129bdc986f4f..6bc5f1f680a3551a02a6cd252622bd66b463006c 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -5,6 +5,8 @@ namespace Illuminate\Foundation; use Closure; use Illuminate\Container\Container; use Illuminate\Contracts\Foundation\Application as ApplicationContract; +use Illuminate\Contracts\Foundation\CachesConfiguration; +use Illuminate\Contracts\Foundation\CachesRoutes; use Illuminate\Contracts\Http\Kernel as HttpKernelContract; use Illuminate\Events\EventServiceProvider; use Illuminate\Filesystem\Filesystem; @@ -24,14 +26,14 @@ use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; -class Application extends Container implements ApplicationContract, HttpKernelInterface +class Application extends Container implements ApplicationContract, CachesConfiguration, CachesRoutes, HttpKernelInterface { /** * The Laravel framework version. * * @var string */ - const VERSION = '6.20.14'; + const VERSION = '8.83.26'; /** * The base path for the Laravel installation. @@ -110,6 +112,13 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ protected $databasePath; + /** + * The custom language file path defined by the developer. + * + * @var string + */ + protected $langPath; + /** * The custom storage path defined by the developer. * @@ -145,6 +154,13 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ protected $namespace; + /** + * The prefixes of absolute cache paths for use during normalization. + * + * @var string[] + */ + protected $absoluteCachePathPrefixes = ['/', '\\']; + /** * Create a new Illuminate application instance. * @@ -186,9 +202,11 @@ class Application extends Container implements ApplicationContract, HttpKernelIn $this->instance(Container::class, $this); $this->singleton(Mix::class); - $this->instance(PackageManifest::class, new PackageManifest( - new Filesystem, $this->basePath(), $this->getCachedPackagesPath() - )); + $this->singleton(PackageManifest::class, function () { + return new PackageManifest( + new Filesystem, $this->basePath(), $this->getCachedPackagesPath() + ); + }); } /** @@ -230,7 +248,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function afterLoadingEnvironment(Closure $callback) { - return $this->afterBootstrapping( + $this->afterBootstrapping( LoadEnvironmentVariables::class, $callback ); } @@ -333,7 +351,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn /** * Get the base path of the Laravel installation. * - * @param string $path Optionally, a path to append to the base path + * @param string $path * @return string */ public function basePath($path = '') @@ -344,7 +362,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn /** * Get the path to the bootstrap directory. * - * @param string $path Optionally, a path to append to the bootstrap path + * @param string $path * @return string */ public function bootstrapPath($path = '') @@ -355,7 +373,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn /** * Get the path to the application configuration files. * - * @param string $path Optionally, a path to append to the config path + * @param string $path * @return string */ public function configPath($path = '') @@ -366,7 +384,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn /** * Get the path to the database directory. * - * @param string $path Optionally, a path to append to the database path + * @param string $path * @return string */ public function databasePath($path = '') @@ -396,7 +414,30 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function langPath() { - return $this->resourcePath().DIRECTORY_SEPARATOR.'lang'; + if ($this->langPath) { + return $this->langPath; + } + + if (is_dir($path = $this->resourcePath().DIRECTORY_SEPARATOR.'lang')) { + return $path; + } + + return $this->basePath().DIRECTORY_SEPARATOR.'lang'; + } + + /** + * Set the language file directory. + * + * @param string $path + * @return $this + */ + public function useLangPath($path) + { + $this->langPath = $path; + + $this->instance('path.lang', $path); + + return $this; } /** @@ -445,6 +486,21 @@ class Application extends Container implements ApplicationContract, HttpKernelIn return $this->basePath.DIRECTORY_SEPARATOR.'resources'.($path ? DIRECTORY_SEPARATOR.$path : $path); } + /** + * Get the path to the views directory. + * + * This method returns the first configured path in the array of view paths. + * + * @param string $path + * @return string + */ + public function viewPath($path = '') + { + $basePath = $this['config']->get('view.paths')[0]; + + return rtrim($basePath, DIRECTORY_SEPARATOR).($path ? DIRECTORY_SEPARATOR.$path : $path); + } + /** * Get the path to the environment file directory. * @@ -519,7 +575,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn } /** - * Determine if application is in local environment. + * Determine if the application is in the local environment. * * @return bool */ @@ -529,7 +585,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn } /** - * Determine if application is in production environment. + * Determine if the application is in the production environment. * * @return bool */ @@ -572,7 +628,17 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function runningUnitTests() { - return $this['env'] === 'testing'; + return $this->bound('env') && $this['env'] === 'testing'; + } + + /** + * Determine if the application is running with debug mode enabled. + * + * @return bool + */ + public function hasDebugModeEnabled() + { + return (bool) $this['config']->get('app.debug'); } /** @@ -582,7 +648,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function registerConfiguredProviders() { - $providers = Collection::make($this->config['app.providers']) + $providers = Collection::make($this->make('config')->get('app.providers')) ->partition(function ($provider) { return strpos($provider, 'Illuminate\\') === 0; }); @@ -848,13 +914,17 @@ class Application extends Container implements ApplicationContract, HttpKernelIn * Boot the given service provider. * * @param \Illuminate\Support\ServiceProvider $provider - * @return mixed + * @return void */ protected function bootProvider(ServiceProvider $provider) { + $provider->callBootingCallbacks(); + if (method_exists($provider, 'boot')) { - return $this->call([$provider, 'boot']); + $this->call([$provider, 'boot']); } + + $provider->callBootedCallbacks(); } /** @@ -879,7 +949,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn $this->bootedCallbacks[] = $callback; if ($this->isBooted()) { - $this->fireAppCallbacks([$callback]); + $callback($this); } } @@ -889,17 +959,23 @@ class Application extends Container implements ApplicationContract, HttpKernelIn * @param callable[] $callbacks * @return void */ - protected function fireAppCallbacks(array $callbacks) + protected function fireAppCallbacks(array &$callbacks) { - foreach ($callbacks as $callback) { - $callback($this); + $index = 0; + + while ($index < count($callbacks)) { + $callbacks[$index]($this); + + $index++; } } /** * {@inheritdoc} + * + * @return \Symfony\Component\HttpFoundation\Response */ - public function handle(SymfonyRequest $request, $type = self::MASTER_REQUEST, $catch = true) + public function handle(SymfonyRequest $request, int $type = self::MASTER_REQUEST, bool $catch = true) { return $this[HttpKernelContract::class]->handle(Request::createFromBase($request)); } @@ -942,7 +1018,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function configurationIsCached() { - return file_exists($this->getCachedConfigPath()); + return is_file($this->getCachedConfigPath()); } /** @@ -972,7 +1048,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function getCachedRoutesPath() { - return $this->normalizeCachePath('APP_ROUTES_CACHE', 'cache/routes.php'); + return $this->normalizeCachePath('APP_ROUTES_CACHE', 'cache/routes-v7.php'); } /** @@ -1008,11 +1084,24 @@ class Application extends Container implements ApplicationContract, HttpKernelIn return $this->bootstrapPath($default); } - return Str::startsWith($env, '/') + return Str::startsWith($env, $this->absoluteCachePathPrefixes) ? $env : $this->basePath($env); } + /** + * Add new prefix to list of absolute path prefixes. + * + * @param string $prefix + * @return $this + */ + public function addAbsoluteCachePathPrefix($prefix) + { + $this->absoluteCachePathPrefixes[] = $prefix; + + return $this; + } + /** * Determine if the application is currently down for maintenance. * @@ -1029,7 +1118,7 @@ class Application extends Container implements ApplicationContract, HttpKernelIn * @param int $code * @param string $message * @param array $headers - * @return void + * @return never * * @throws \Symfony\Component\HttpKernel\Exception\HttpException * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException @@ -1063,8 +1152,12 @@ class Application extends Container implements ApplicationContract, HttpKernelIn */ public function terminate() { - foreach ($this->terminatingCallbacks as $terminating) { - $this->call($terminating); + $index = 0; + + while ($index < count($this->terminatingCallbacks)) { + $this->call($this->terminatingCallbacks[$index]); + + $index++; } } @@ -1078,6 +1171,17 @@ class Application extends Container implements ApplicationContract, HttpKernelIn return $this->loadedProviders; } + /** + * Determine if the given service provider is loaded. + * + * @param string $provider + * @return bool + */ + public function providerIsLoaded(string $provider) + { + return isset($this->loadedProviders[$provider]); + } + /** * Get the application's deferred services. * @@ -1142,6 +1246,26 @@ class Application extends Container implements ApplicationContract, HttpKernelIn return $this['config']->get('app.locale'); } + /** + * Get the current application locale. + * + * @return string + */ + public function currentLocale() + { + return $this->getLocale(); + } + + /** + * Get the current application fallback locale. + * + * @return string + */ + public function getFallbackLocale() + { + return $this['config']->get('app.fallback_locale'); + } + /** * Set the current application locale. * @@ -1158,7 +1282,20 @@ class Application extends Container implements ApplicationContract, HttpKernelIn } /** - * Determine if application locale is the given locale. + * Set the current application fallback locale. + * + * @param string $fallbackLocale + * @return void + */ + public function setFallbackLocale($fallbackLocale) + { + $this['config']->set('app.fallback_locale', $fallbackLocale); + + $this['translator']->setFallback($fallbackLocale); + } + + /** + * Determine if the application locale is the given locale. * * @param string $locale * @return bool @@ -1176,43 +1313,44 @@ class Application extends Container implements ApplicationContract, HttpKernelIn public function registerCoreContainerAliases() { foreach ([ - 'app' => [self::class, \Illuminate\Contracts\Container\Container::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class], - 'auth' => [\Illuminate\Auth\AuthManager::class, \Illuminate\Contracts\Auth\Factory::class], - 'auth.driver' => [\Illuminate\Contracts\Auth\Guard::class], - 'blade.compiler' => [\Illuminate\View\Compilers\BladeCompiler::class], - 'cache' => [\Illuminate\Cache\CacheManager::class, \Illuminate\Contracts\Cache\Factory::class], - 'cache.store' => [\Illuminate\Cache\Repository::class, \Illuminate\Contracts\Cache\Repository::class, \Psr\SimpleCache\CacheInterface::class], - 'cache.psr6' => [\Symfony\Component\Cache\Adapter\Psr16Adapter::class, \Symfony\Component\Cache\Adapter\AdapterInterface::class, \Psr\Cache\CacheItemPoolInterface::class], - 'config' => [\Illuminate\Config\Repository::class, \Illuminate\Contracts\Config\Repository::class], - 'cookie' => [\Illuminate\Cookie\CookieJar::class, \Illuminate\Contracts\Cookie\Factory::class, \Illuminate\Contracts\Cookie\QueueingFactory::class], - 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class], - 'db' => [\Illuminate\Database\DatabaseManager::class, \Illuminate\Database\ConnectionResolverInterface::class], - 'db.connection' => [\Illuminate\Database\Connection::class, \Illuminate\Database\ConnectionInterface::class], - 'events' => [\Illuminate\Events\Dispatcher::class, \Illuminate\Contracts\Events\Dispatcher::class], - 'files' => [\Illuminate\Filesystem\Filesystem::class], - 'filesystem' => [\Illuminate\Filesystem\FilesystemManager::class, \Illuminate\Contracts\Filesystem\Factory::class], - 'filesystem.disk' => [\Illuminate\Contracts\Filesystem\Filesystem::class], - 'filesystem.cloud' => [\Illuminate\Contracts\Filesystem\Cloud::class], - 'hash' => [\Illuminate\Hashing\HashManager::class], - 'hash.driver' => [\Illuminate\Contracts\Hashing\Hasher::class], - 'translator' => [\Illuminate\Translation\Translator::class, \Illuminate\Contracts\Translation\Translator::class], - 'log' => [\Illuminate\Log\LogManager::class, \Psr\Log\LoggerInterface::class], - 'mailer' => [\Illuminate\Mail\Mailer::class, \Illuminate\Contracts\Mail\Mailer::class, \Illuminate\Contracts\Mail\MailQueue::class], - 'auth.password' => [\Illuminate\Auth\Passwords\PasswordBrokerManager::class, \Illuminate\Contracts\Auth\PasswordBrokerFactory::class], + 'app' => [self::class, \Illuminate\Contracts\Container\Container::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class], + 'auth' => [\Illuminate\Auth\AuthManager::class, \Illuminate\Contracts\Auth\Factory::class], + 'auth.driver' => [\Illuminate\Contracts\Auth\Guard::class], + 'blade.compiler' => [\Illuminate\View\Compilers\BladeCompiler::class], + 'cache' => [\Illuminate\Cache\CacheManager::class, \Illuminate\Contracts\Cache\Factory::class], + 'cache.store' => [\Illuminate\Cache\Repository::class, \Illuminate\Contracts\Cache\Repository::class, \Psr\SimpleCache\CacheInterface::class], + 'cache.psr6' => [\Symfony\Component\Cache\Adapter\Psr16Adapter::class, \Symfony\Component\Cache\Adapter\AdapterInterface::class, \Psr\Cache\CacheItemPoolInterface::class], + 'config' => [\Illuminate\Config\Repository::class, \Illuminate\Contracts\Config\Repository::class], + 'cookie' => [\Illuminate\Cookie\CookieJar::class, \Illuminate\Contracts\Cookie\Factory::class, \Illuminate\Contracts\Cookie\QueueingFactory::class], + 'db' => [\Illuminate\Database\DatabaseManager::class, \Illuminate\Database\ConnectionResolverInterface::class], + 'db.connection' => [\Illuminate\Database\Connection::class, \Illuminate\Database\ConnectionInterface::class], + 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\StringEncrypter::class], + 'events' => [\Illuminate\Events\Dispatcher::class, \Illuminate\Contracts\Events\Dispatcher::class], + 'files' => [\Illuminate\Filesystem\Filesystem::class], + 'filesystem' => [\Illuminate\Filesystem\FilesystemManager::class, \Illuminate\Contracts\Filesystem\Factory::class], + 'filesystem.disk' => [\Illuminate\Contracts\Filesystem\Filesystem::class], + 'filesystem.cloud' => [\Illuminate\Contracts\Filesystem\Cloud::class], + 'hash' => [\Illuminate\Hashing\HashManager::class], + 'hash.driver' => [\Illuminate\Contracts\Hashing\Hasher::class], + 'translator' => [\Illuminate\Translation\Translator::class, \Illuminate\Contracts\Translation\Translator::class], + 'log' => [\Illuminate\Log\LogManager::class, \Psr\Log\LoggerInterface::class], + 'mail.manager' => [\Illuminate\Mail\MailManager::class, \Illuminate\Contracts\Mail\Factory::class], + 'mailer' => [\Illuminate\Mail\Mailer::class, \Illuminate\Contracts\Mail\Mailer::class, \Illuminate\Contracts\Mail\MailQueue::class], + 'auth.password' => [\Illuminate\Auth\Passwords\PasswordBrokerManager::class, \Illuminate\Contracts\Auth\PasswordBrokerFactory::class], 'auth.password.broker' => [\Illuminate\Auth\Passwords\PasswordBroker::class, \Illuminate\Contracts\Auth\PasswordBroker::class], - 'queue' => [\Illuminate\Queue\QueueManager::class, \Illuminate\Contracts\Queue\Factory::class, \Illuminate\Contracts\Queue\Monitor::class], - 'queue.connection' => [\Illuminate\Contracts\Queue\Queue::class], - 'queue.failer' => [\Illuminate\Queue\Failed\FailedJobProviderInterface::class], - 'redirect' => [\Illuminate\Routing\Redirector::class], - 'redis' => [\Illuminate\Redis\RedisManager::class, \Illuminate\Contracts\Redis\Factory::class], - 'redis.connection' => [\Illuminate\Redis\Connections\Connection::class, \Illuminate\Contracts\Redis\Connection::class], - 'request' => [\Illuminate\Http\Request::class, \Symfony\Component\HttpFoundation\Request::class], - 'router' => [\Illuminate\Routing\Router::class, \Illuminate\Contracts\Routing\Registrar::class, \Illuminate\Contracts\Routing\BindingRegistrar::class], - 'session' => [\Illuminate\Session\SessionManager::class], - 'session.store' => [\Illuminate\Session\Store::class, \Illuminate\Contracts\Session\Session::class], - 'url' => [\Illuminate\Routing\UrlGenerator::class, \Illuminate\Contracts\Routing\UrlGenerator::class], - 'validator' => [\Illuminate\Validation\Factory::class, \Illuminate\Contracts\Validation\Factory::class], - 'view' => [\Illuminate\View\Factory::class, \Illuminate\Contracts\View\Factory::class], + 'queue' => [\Illuminate\Queue\QueueManager::class, \Illuminate\Contracts\Queue\Factory::class, \Illuminate\Contracts\Queue\Monitor::class], + 'queue.connection' => [\Illuminate\Contracts\Queue\Queue::class], + 'queue.failer' => [\Illuminate\Queue\Failed\FailedJobProviderInterface::class], + 'redirect' => [\Illuminate\Routing\Redirector::class], + 'redis' => [\Illuminate\Redis\RedisManager::class, \Illuminate\Contracts\Redis\Factory::class], + 'redis.connection' => [\Illuminate\Redis\Connections\Connection::class, \Illuminate\Contracts\Redis\Connection::class], + 'request' => [\Illuminate\Http\Request::class, \Symfony\Component\HttpFoundation\Request::class], + 'router' => [\Illuminate\Routing\Router::class, \Illuminate\Contracts\Routing\Registrar::class, \Illuminate\Contracts\Routing\BindingRegistrar::class], + 'session' => [\Illuminate\Session\SessionManager::class], + 'session.store' => [\Illuminate\Session\Store::class, \Illuminate\Contracts\Session\Session::class], + 'url' => [\Illuminate\Routing\UrlGenerator::class, \Illuminate\Contracts\Routing\UrlGenerator::class], + 'validator' => [\Illuminate\Validation\Factory::class, \Illuminate\Contracts\Validation\Factory::class], + 'view' => [\Illuminate\View\Factory::class, \Illuminate\Contracts\View\Factory::class], ] as $key => $aliases) { foreach ($aliases as $alias) { $this->alias($key, $alias); @@ -1238,8 +1376,11 @@ class Application extends Container implements ApplicationContract, HttpKernelIn $this->serviceProviders = []; $this->resolvingCallbacks = []; $this->terminatingCallbacks = []; + $this->beforeResolvingCallbacks = []; $this->afterResolvingCallbacks = []; + $this->globalBeforeResolvingCallbacks = []; $this->globalResolvingCallbacks = []; + $this->globalAfterResolvingCallbacks = []; } /** diff --git a/src/Illuminate/Foundation/Auth/Access/Authorizable.php b/src/Illuminate/Foundation/Auth/Access/Authorizable.php index dd0ba609fbab12d1d837b82dd342cfb4bd1a3fc5..d8cf50dbc5373f1b05089841e2ba48cb7fe04253 100644 --- a/src/Illuminate/Foundation/Auth/Access/Authorizable.php +++ b/src/Illuminate/Foundation/Auth/Access/Authorizable.php @@ -18,6 +18,18 @@ trait Authorizable return app(Gate::class)->forUser($this)->check($abilities, $arguments); } + /** + * Determine if the entity has any of the given abilities. + * + * @param iterable|string $abilities + * @param array|mixed $arguments + * @return bool + */ + public function canAny($abilities, $arguments = []) + { + return app(Gate::class)->forUser($this)->any($abilities, $arguments); + } + /** * Determine if the entity does not have the given abilities. * diff --git a/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php b/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php index 85a9596f9c57dbaa956118ad38c0dd8afc8d55c1..fe0ba0c58f8615465e75d6f35148722781752e83 100644 --- a/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php +++ b/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php @@ -74,14 +74,18 @@ trait AuthorizesRequests /** * Authorize a resource action based on the incoming request. * - * @param string $model - * @param string|null $parameter + * @param string|array $model + * @param string|array|null $parameter * @param array $options * @param \Illuminate\Http\Request|null $request * @return void */ public function authorizeResource($model, $parameter = null, array $options = [], $request = null) { + $model = is_array($model) ? implode(',', $model) : $model; + + $parameter = is_array($parameter) ? implode(',', $parameter) : $parameter; + $parameter = $parameter ?: Str::snake(class_basename($model)); $middleware = []; diff --git a/src/Illuminate/Foundation/Auth/AuthenticatesUsers.php b/src/Illuminate/Foundation/Auth/AuthenticatesUsers.php deleted file mode 100644 index 39f25f7dcbd66eaae77ea4e3ce81c2849eae85be..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/AuthenticatesUsers.php +++ /dev/null @@ -1,187 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Validation\ValidationException; - -trait AuthenticatesUsers -{ - use RedirectsUsers, ThrottlesLogins; - - /** - * Show the application's login form. - * - * @return \Illuminate\Http\Response - */ - public function showLoginForm() - { - return view('auth.login'); - } - - /** - * Handle a login request to the application. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse - * - * @throws \Illuminate\Validation\ValidationException - */ - public function login(Request $request) - { - $this->validateLogin($request); - - // If the class is using the ThrottlesLogins trait, we can automatically throttle - // the login attempts for this application. We'll key this by the username and - // the IP address of the client making these requests into this application. - if (method_exists($this, 'hasTooManyLoginAttempts') && - $this->hasTooManyLoginAttempts($request)) { - $this->fireLockoutEvent($request); - - return $this->sendLockoutResponse($request); - } - - if ($this->attemptLogin($request)) { - return $this->sendLoginResponse($request); - } - - // If the login attempt was unsuccessful we will increment the number of attempts - // to login and redirect the user back to the login form. Of course, when this - // user surpasses their maximum number of attempts they will get locked out. - $this->incrementLoginAttempts($request); - - return $this->sendFailedLoginResponse($request); - } - - /** - * Validate the user login request. - * - * @param \Illuminate\Http\Request $request - * @return void - * - * @throws \Illuminate\Validation\ValidationException - */ - protected function validateLogin(Request $request) - { - $request->validate([ - $this->username() => 'required|string', - 'password' => 'required|string', - ]); - } - - /** - * Attempt to log the user into the application. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - protected function attemptLogin(Request $request) - { - return $this->guard()->attempt( - $this->credentials($request), $request->filled('remember') - ); - } - - /** - * Get the needed authorization credentials from the request. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function credentials(Request $request) - { - return $request->only($this->username(), 'password'); - } - - /** - * Send the response after the user was authenticated. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - protected function sendLoginResponse(Request $request) - { - $request->session()->regenerate(); - - $this->clearLoginAttempts($request); - - return $this->authenticated($request, $this->guard()->user()) - ?: redirect()->intended($this->redirectPath()); - } - - /** - * The user has been authenticated. - * - * @param \Illuminate\Http\Request $request - * @param mixed $user - * @return mixed - */ - protected function authenticated(Request $request, $user) - { - // - } - - /** - * Get the failed login response instance. - * - * @param \Illuminate\Http\Request $request - * @return \Symfony\Component\HttpFoundation\Response - * - * @throws \Illuminate\Validation\ValidationException - */ - protected function sendFailedLoginResponse(Request $request) - { - throw ValidationException::withMessages([ - $this->username() => [trans('auth.failed')], - ]); - } - - /** - * Get the login username to be used by the controller. - * - * @return string - */ - public function username() - { - return 'email'; - } - - /** - * Log the user out of the application. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function logout(Request $request) - { - $this->guard()->logout(); - - $request->session()->invalidate(); - - $request->session()->regenerateToken(); - - return $this->loggedOut($request) ?: redirect('/'); - } - - /** - * The user has logged out of the application. - * - * @param \Illuminate\Http\Request $request - * @return mixed - */ - protected function loggedOut(Request $request) - { - // - } - - /** - * Get the guard to be used during authentication. - * - * @return \Illuminate\Contracts\Auth\StatefulGuard - */ - protected function guard() - { - return Auth::guard(); - } -} diff --git a/src/Illuminate/Foundation/Auth/ConfirmsPasswords.php b/src/Illuminate/Foundation/Auth/ConfirmsPasswords.php deleted file mode 100644 index 655c4e5bef2d01a62f91b44f8e4701130a179005..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/ConfirmsPasswords.php +++ /dev/null @@ -1,68 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Http\Request; - -trait ConfirmsPasswords -{ - use RedirectsUsers; - - /** - * Display the password confirmation view. - * - * @return \Illuminate\Http\Response - */ - public function showConfirmForm() - { - return view('auth.passwords.confirm'); - } - - /** - * Confirm the given user's password. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - public function confirm(Request $request) - { - $request->validate($this->rules(), $this->validationErrorMessages()); - - $this->resetPasswordConfirmationTimeout($request); - - return redirect()->intended($this->redirectPath()); - } - - /** - * Reset the password confirmation timeout. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function resetPasswordConfirmationTimeout(Request $request) - { - $request->session()->put('auth.password_confirmed_at', time()); - } - - /** - * Get the password confirmation validation rules. - * - * @return array - */ - protected function rules() - { - return [ - 'password' => 'required|password', - ]; - } - - /** - * Get the password confirmation validation error messages. - * - * @return array - */ - protected function validationErrorMessages() - { - return []; - } -} diff --git a/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php b/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..c9c43046ed2c68ff95df1aee0c984efb71570ffb --- /dev/null +++ b/src/Illuminate/Foundation/Auth/EmailVerificationRequest.php @@ -0,0 +1,66 @@ +<?php + +namespace Illuminate\Foundation\Auth; + +use Illuminate\Auth\Events\Verified; +use Illuminate\Foundation\Http\FormRequest; + +class EmailVerificationRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + * + * @return bool + */ + public function authorize() + { + if (! hash_equals((string) $this->route('id'), + (string) $this->user()->getKey())) { + return false; + } + + if (! hash_equals((string) $this->route('hash'), + sha1($this->user()->getEmailForVerification()))) { + return false; + } + + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } + + /** + * Fulfill the email verification request. + * + * @return void + */ + public function fulfill() + { + if (! $this->user()->hasVerifiedEmail()) { + $this->user()->markEmailAsVerified(); + + event(new Verified($this->user())); + } + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + return $validator; + } +} diff --git a/src/Illuminate/Foundation/Auth/RedirectsUsers.php b/src/Illuminate/Foundation/Auth/RedirectsUsers.php deleted file mode 100644 index cc992290e7ad2266debe682ff0d087069fc5e5b2..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/RedirectsUsers.php +++ /dev/null @@ -1,20 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -trait RedirectsUsers -{ - /** - * Get the post register / login redirect path. - * - * @return string - */ - public function redirectPath() - { - if (method_exists($this, 'redirectTo')) { - return $this->redirectTo(); - } - - return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; - } -} diff --git a/src/Illuminate/Foundation/Auth/RegistersUsers.php b/src/Illuminate/Foundation/Auth/RegistersUsers.php deleted file mode 100644 index 4dc43425560e7f431c3f84d2f8c18e99f1335199..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/RegistersUsers.php +++ /dev/null @@ -1,62 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Auth\Events\Registered; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; - -trait RegistersUsers -{ - use RedirectsUsers; - - /** - * Show the application registration form. - * - * @return \Illuminate\Http\Response - */ - public function showRegistrationForm() - { - return view('auth.register'); - } - - /** - * Handle a registration request for the application. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function register(Request $request) - { - $this->validator($request->all())->validate(); - - event(new Registered($user = $this->create($request->all()))); - - $this->guard()->login($user); - - return $this->registered($request, $user) - ?: redirect($this->redirectPath()); - } - - /** - * Get the guard to be used during registration. - * - * @return \Illuminate\Contracts\Auth\StatefulGuard - */ - protected function guard() - { - return Auth::guard(); - } - - /** - * The user has been registered. - * - * @param \Illuminate\Http\Request $request - * @param mixed $user - * @return mixed - */ - protected function registered(Request $request, $user) - { - // - } -} diff --git a/src/Illuminate/Foundation/Auth/ResetsPasswords.php b/src/Illuminate/Foundation/Auth/ResetsPasswords.php deleted file mode 100644 index a1567e0c0f035d3c2e307464c5933ebea2ec1bf0..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/ResetsPasswords.php +++ /dev/null @@ -1,174 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Auth\Events\PasswordReset; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Password; -use Illuminate\Support\Str; - -trait ResetsPasswords -{ - use RedirectsUsers; - - /** - * Display the password reset view for the given token. - * - * If no token is present, display the link request form. - * - * @param \Illuminate\Http\Request $request - * @param string|null $token - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View - */ - public function showResetForm(Request $request, $token = null) - { - return view('auth.passwords.reset')->with( - ['token' => $token, 'email' => $request->email] - ); - } - - /** - * Reset the given user's password. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - public function reset(Request $request) - { - $request->validate($this->rules(), $this->validationErrorMessages()); - - // Here we will attempt to reset the user's password. If it is successful we - // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. - $response = $this->broker()->reset( - $this->credentials($request), function ($user, $password) { - $this->resetPassword($user, $password); - } - ); - - // If the password was successfully reset, we will redirect the user back to - // the application's home authenticated view. If there is an error we can - // redirect them back to where they came from with their error message. - return $response == Password::PASSWORD_RESET - ? $this->sendResetResponse($request, $response) - : $this->sendResetFailedResponse($request, $response); - } - - /** - * Get the password reset validation rules. - * - * @return array - */ - protected function rules() - { - return [ - 'token' => 'required', - 'email' => 'required|email', - 'password' => 'required|confirmed|min:8', - ]; - } - - /** - * Get the password reset validation error messages. - * - * @return array - */ - protected function validationErrorMessages() - { - return []; - } - - /** - * Get the password reset credentials from the request. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function credentials(Request $request) - { - return $request->only( - 'email', 'password', 'password_confirmation', 'token' - ); - } - - /** - * Reset the given user's password. - * - * @param \Illuminate\Contracts\Auth\CanResetPassword $user - * @param string $password - * @return void - */ - protected function resetPassword($user, $password) - { - $this->setUserPassword($user, $password); - - $user->setRememberToken(Str::random(60)); - - $user->save(); - - event(new PasswordReset($user)); - - $this->guard()->login($user); - } - - /** - * Set the user's password. - * - * @param \Illuminate\Contracts\Auth\CanResetPassword $user - * @param string $password - * @return void - */ - protected function setUserPassword($user, $password) - { - $user->password = Hash::make($password); - } - - /** - * Get the response for a successful password reset. - * - * @param \Illuminate\Http\Request $request - * @param string $response - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - protected function sendResetResponse(Request $request, $response) - { - return redirect($this->redirectPath()) - ->with('status', trans($response)); - } - - /** - * Get the response for a failed password reset. - * - * @param \Illuminate\Http\Request $request - * @param string $response - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - protected function sendResetFailedResponse(Request $request, $response) - { - return redirect()->back() - ->withInput($request->only('email')) - ->withErrors(['email' => trans($response)]); - } - - /** - * Get the broker to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\PasswordBroker - */ - public function broker() - { - return Password::broker(); - } - - /** - * Get the guard to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\StatefulGuard - */ - protected function guard() - { - return Auth::guard(); - } -} diff --git a/src/Illuminate/Foundation/Auth/SendsPasswordResetEmails.php b/src/Illuminate/Foundation/Auth/SendsPasswordResetEmails.php deleted file mode 100644 index 070c6482012ac9743d2cd26d19b8cd0a7b59890e..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/SendsPasswordResetEmails.php +++ /dev/null @@ -1,99 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Password; - -trait SendsPasswordResetEmails -{ - /** - * Display the form to request a password reset link. - * - * @return \Illuminate\Http\Response - */ - public function showLinkRequestForm() - { - return view('auth.passwords.email'); - } - - /** - * Send a reset link to the given user. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - public function sendResetLinkEmail(Request $request) - { - $this->validateEmail($request); - - // We will send the password reset link to this user. Once we have attempted - // to send the link, we will examine the response then see the message we - // need to show to the user. Finally, we'll send out a proper response. - $response = $this->broker()->sendResetLink( - $this->credentials($request) - ); - - return $response == Password::RESET_LINK_SENT - ? $this->sendResetLinkResponse($request, $response) - : $this->sendResetLinkFailedResponse($request, $response); - } - - /** - * Validate the email for the given request. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function validateEmail(Request $request) - { - $request->validate(['email' => 'required|email']); - } - - /** - * Get the needed authentication credentials from the request. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function credentials(Request $request) - { - return $request->only('email'); - } - - /** - * Get the response for a successful password reset link. - * - * @param \Illuminate\Http\Request $request - * @param string $response - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - protected function sendResetLinkResponse(Request $request, $response) - { - return back()->with('status', trans($response)); - } - - /** - * Get the response for a failed password reset link. - * - * @param \Illuminate\Http\Request $request - * @param string $response - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - protected function sendResetLinkFailedResponse(Request $request, $response) - { - return back() - ->withInput($request->only('email')) - ->withErrors(['email' => trans($response)]); - } - - /** - * Get the broker to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\PasswordBroker - */ - public function broker() - { - return Password::broker(); - } -} diff --git a/src/Illuminate/Foundation/Auth/ThrottlesLogins.php b/src/Illuminate/Foundation/Auth/ThrottlesLogins.php deleted file mode 100644 index 8f63237778b0e57c3de346920b62aee139959164..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/ThrottlesLogins.php +++ /dev/null @@ -1,125 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Auth\Events\Lockout; -use Illuminate\Cache\RateLimiter; -use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Illuminate\Support\Facades\Lang; -use Illuminate\Support\Str; -use Illuminate\Validation\ValidationException; - -trait ThrottlesLogins -{ - /** - * Determine if the user has too many failed login attempts. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - protected function hasTooManyLoginAttempts(Request $request) - { - return $this->limiter()->tooManyAttempts( - $this->throttleKey($request), $this->maxAttempts() - ); - } - - /** - * Increment the login attempts for the user. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function incrementLoginAttempts(Request $request) - { - $this->limiter()->hit( - $this->throttleKey($request), $this->decayMinutes() * 60 - ); - } - - /** - * Redirect the user after determining they are locked out. - * - * @param \Illuminate\Http\Request $request - * @return void - * - * @throws \Illuminate\Validation\ValidationException - */ - protected function sendLockoutResponse(Request $request) - { - $seconds = $this->limiter()->availableIn( - $this->throttleKey($request) - ); - - throw ValidationException::withMessages([ - $this->username() => [Lang::get('auth.throttle', [ - 'seconds' => $seconds, - 'minutes' => ceil($seconds / 60), - ])], - ])->status(Response::HTTP_TOO_MANY_REQUESTS); - } - - /** - * Clear the login locks for the given user credentials. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function clearLoginAttempts(Request $request) - { - $this->limiter()->clear($this->throttleKey($request)); - } - - /** - * Fire an event when a lockout occurs. - * - * @param \Illuminate\Http\Request $request - * @return void - */ - protected function fireLockoutEvent(Request $request) - { - event(new Lockout($request)); - } - - /** - * Get the throttle key for the given request. - * - * @param \Illuminate\Http\Request $request - * @return string - */ - protected function throttleKey(Request $request) - { - return Str::lower($request->input($this->username())).'|'.$request->ip(); - } - - /** - * Get the rate limiter instance. - * - * @return \Illuminate\Cache\RateLimiter - */ - protected function limiter() - { - return app(RateLimiter::class); - } - - /** - * Get the maximum number of attempts to allow. - * - * @return int - */ - public function maxAttempts() - { - return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5; - } - - /** - * Get the number of minutes to throttle for. - * - * @return int - */ - public function decayMinutes() - { - return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1; - } -} diff --git a/src/Illuminate/Foundation/Auth/VerifiesEmails.php b/src/Illuminate/Foundation/Auth/VerifiesEmails.php deleted file mode 100644 index 88a19f07e7038139846ca4e25bccab5e2ceea956..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Auth/VerifiesEmails.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Auth; - -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Auth\Events\Verified; -use Illuminate\Http\Request; - -trait VerifiesEmails -{ - use RedirectsUsers; - - /** - * Show the email verification notice. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function show(Request $request) - { - return $request->user()->hasVerifiedEmail() - ? redirect($this->redirectPath()) - : view('auth.verify'); - } - - /** - * Mark the authenticated user's email address as verified. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - * - * @throws \Illuminate\Auth\Access\AuthorizationException - */ - public function verify(Request $request) - { - if (! hash_equals((string) $request->route('id'), (string) $request->user()->getKey())) { - throw new AuthorizationException; - } - - if (! hash_equals((string) $request->route('hash'), sha1($request->user()->getEmailForVerification()))) { - throw new AuthorizationException; - } - - if ($request->user()->hasVerifiedEmail()) { - return redirect($this->redirectPath()); - } - - if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); - } - - return redirect($this->redirectPath())->with('verified', true); - } - - /** - * Resend the email verification notification. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function resend(Request $request) - { - if ($request->user()->hasVerifiedEmail()) { - return redirect($this->redirectPath()); - } - - $request->user()->sendEmailVerificationNotification(); - - return back()->with('resent', true); - } -} diff --git a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php index 9da4d463118d4586f452d39bebb4da6f4661d817..286c2fec35109f79c93fd77f5a13ef83449d8d0c 100644 --- a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php +++ b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php @@ -6,9 +6,11 @@ use ErrorException; use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Log\LogManager; +use Monolog\Handler\NullHandler; use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Debug\Exception\FatalErrorException; -use Symfony\Component\Debug\Exception\FatalThrowableError; +use Symfony\Component\ErrorHandler\Error\FatalError; +use Throwable; class HandleExceptions { @@ -34,7 +36,7 @@ class HandleExceptions */ public function bootstrap(Application $app) { - self::$reservedMemory = str_repeat('x', 10240); + self::$reservedMemory = str_repeat('x', 32768); $this->app = $app; @@ -52,7 +54,7 @@ class HandleExceptions } /** - * Convert PHP errors to ErrorException instances. + * Report PHP deprecations, or convert PHP errors to ErrorException instances. * * @param int $level * @param string $message @@ -65,11 +67,86 @@ class HandleExceptions */ public function handleError($level, $message, $file = '', $line = 0, $context = []) { + if ($this->isDeprecation($level)) { + return $this->handleDeprecation($message, $file, $line); + } + if (error_reporting() & $level) { throw new ErrorException($message, 0, $level, $file, $line); } } + /** + * Reports a deprecation to the "deprecations" logger. + * + * @param string $message + * @param string $file + * @param int $line + * @return void + */ + public function handleDeprecation($message, $file, $line) + { + if (! class_exists(LogManager::class) + || ! $this->app->hasBeenBootstrapped() + || $this->app->runningUnitTests() + ) { + return; + } + + try { + $logger = $this->app->make(LogManager::class); + } catch (Exception $e) { + return; + } + + $this->ensureDeprecationLoggerIsConfigured(); + + with($logger->channel('deprecations'), function ($log) use ($message, $file, $line) { + $log->warning(sprintf('%s in %s on line %s', + $message, $file, $line + )); + }); + } + + /** + * Ensure the "deprecations" logger is configured. + * + * @return void + */ + protected function ensureDeprecationLoggerIsConfigured() + { + with($this->app['config'], function ($config) { + if ($config->get('logging.channels.deprecations')) { + return; + } + + $this->ensureNullLogDriverIsConfigured(); + + $driver = $config->get('logging.deprecations') ?? 'null'; + + $config->set('logging.channels.deprecations', $config->get("logging.channels.{$driver}")); + }); + } + + /** + * Ensure the "null" log driver is configured. + * + * @return void + */ + protected function ensureNullLogDriverIsConfigured() + { + with($this->app['config'], function ($config) { + if ($config->get('logging.channels.null')) { + return; + } + + $config->set('logging.channels.null', [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ]); + }); + } + /** * Handle an uncaught exception from the application. * @@ -80,15 +157,11 @@ class HandleExceptions * @param \Throwable $e * @return void */ - public function handleException($e) + public function handleException(Throwable $e) { - if (! $e instanceof Exception) { - $e = new FatalThrowableError($e); - } + self::$reservedMemory = null; try { - self::$reservedMemory = null; - $this->getExceptionHandler()->report($e); } catch (Exception $e) { // @@ -104,10 +177,10 @@ class HandleExceptions /** * Render an exception to the console. * - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function renderForConsole(Exception $e) + protected function renderForConsole(Throwable $e) { $this->getExceptionHandler()->renderForConsole(new ConsoleOutput, $e); } @@ -115,10 +188,10 @@ class HandleExceptions /** * Render an exception as an HTTP response and send it. * - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function renderHttpResponse(Exception $e) + protected function renderHttpResponse(Throwable $e) { $this->getExceptionHandler()->render($this->app['request'], $e)->send(); } @@ -130,23 +203,34 @@ class HandleExceptions */ public function handleShutdown() { + self::$reservedMemory = null; + if (! is_null($error = error_get_last()) && $this->isFatal($error['type'])) { - $this->handleException($this->fatalExceptionFromError($error, 0)); + $this->handleException($this->fatalErrorFromPhpError($error, 0)); } } /** - * Create a new fatal exception instance from an error array. + * Create a new fatal error instance from an error array. * * @param array $error * @param int|null $traceOffset - * @return \Symfony\Component\Debug\Exception\FatalErrorException + * @return \Symfony\Component\ErrorHandler\Error\FatalError + */ + protected function fatalErrorFromPhpError(array $error, $traceOffset = null) + { + return new FatalError($error['message'], 0, $error, $traceOffset); + } + + /** + * Determine if the error level is a deprecation. + * + * @param int $level + * @return bool */ - protected function fatalExceptionFromError(array $error, $traceOffset = null) + protected function isDeprecation($level) { - return new FatalErrorException( - $error['message'], $error['type'], 0, $error['file'], $error['line'], $traceOffset - ); + return in_array($level, [E_DEPRECATED, E_USER_DEPRECATED]); } /** diff --git a/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php index e03340cc002886454704c3856a5262cad949a357..5549a50a1f6fc49a0d973378d2ec9461f20201c5 100644 --- a/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php +++ b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php @@ -68,7 +68,7 @@ class LoadEnvironmentVariables */ protected function setEnvironmentFilePath($app, $file) { - if (file_exists($app->environmentPath().'/'.$file)) { + if (is_file($app->environmentPath().'/'.$file)) { $app->loadEnvironmentFrom($file); return true; @@ -86,9 +86,9 @@ class LoadEnvironmentVariables protected function createDotenv($app) { return Dotenv::create( + Env::getRepository(), $app->environmentPath(), - $app->environmentFile(), - Env::getFactory() + $app->environmentFile() ); } diff --git a/src/Illuminate/Foundation/Bus/Dispatchable.php b/src/Illuminate/Foundation/Bus/Dispatchable.php index c98b063ffba46cbb59615cf165992e462d049c4e..3e90e412026d4c2588bc90e5bd5287d3443b0782 100644 --- a/src/Illuminate/Foundation/Bus/Dispatchable.php +++ b/src/Illuminate/Foundation/Bus/Dispatchable.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Bus; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Support\Fluent; trait Dispatchable { @@ -11,19 +12,61 @@ trait Dispatchable * * @return \Illuminate\Foundation\Bus\PendingDispatch */ - public static function dispatch() + public static function dispatch(...$arguments) { - return new PendingDispatch(new static(...func_get_args())); + return new PendingDispatch(new static(...$arguments)); + } + + /** + * Dispatch the job with the given arguments if the given truth test passes. + * + * @param bool $boolean + * @param mixed ...$arguments + * @return \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent + */ + public static function dispatchIf($boolean, ...$arguments) + { + return $boolean + ? new PendingDispatch(new static(...$arguments)) + : new Fluent; + } + + /** + * Dispatch the job with the given arguments unless the given truth test passes. + * + * @param bool $boolean + * @param mixed ...$arguments + * @return \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent + */ + public static function dispatchUnless($boolean, ...$arguments) + { + return ! $boolean + ? new PendingDispatch(new static(...$arguments)) + : new Fluent; } /** * Dispatch a command to its appropriate handler in the current process. * + * Queueable jobs will be dispatched to the "sync" queue. + * * @return mixed */ - public static function dispatchNow() + public static function dispatchSync(...$arguments) + { + return app(Dispatcher::class)->dispatchSync(new static(...$arguments)); + } + + /** + * Dispatch a command to its appropriate handler in the current process. + * + * @return mixed + * + * @deprecated Will be removed in a future Laravel version. + */ + public static function dispatchNow(...$arguments) { - return app(Dispatcher::class)->dispatchNow(new static(...func_get_args())); + return app(Dispatcher::class)->dispatchNow(new static(...$arguments)); } /** @@ -31,9 +74,9 @@ trait Dispatchable * * @return mixed */ - public static function dispatchAfterResponse() + public static function dispatchAfterResponse(...$arguments) { - return app(Dispatcher::class)->dispatchAfterResponse(new static(...func_get_args())); + return app(Dispatcher::class)->dispatchAfterResponse(new static(...$arguments)); } /** diff --git a/src/Illuminate/Foundation/Bus/DispatchesJobs.php b/src/Illuminate/Foundation/Bus/DispatchesJobs.php index 46d6e5b4dba4d74bae94186b976f05df251d0744..d521158523caa9efab8178b1ffa9814cecf4fb1d 100644 --- a/src/Illuminate/Foundation/Bus/DispatchesJobs.php +++ b/src/Illuminate/Foundation/Bus/DispatchesJobs.php @@ -22,9 +22,24 @@ trait DispatchesJobs * * @param mixed $job * @return mixed + * + * @deprecated Will be removed in a future Laravel version. */ public function dispatchNow($job) { return app(Dispatcher::class)->dispatchNow($job); } + + /** + * Dispatch a job to its appropriate handler in the current process. + * + * Queueable jobs will be dispatched to the "sync" queue. + * + * @param mixed $job + * @return mixed + */ + public function dispatchSync($job) + { + return app(Dispatcher::class)->dispatchSync($job); + } } diff --git a/src/Illuminate/Foundation/Bus/PendingChain.php b/src/Illuminate/Foundation/Bus/PendingChain.php index b34553679358945dbf6d4fa0f853e0c6078ef13a..73ae364c55eaaced1b5274ed85585d8f2a5c16f6 100644 --- a/src/Illuminate/Foundation/Bus/PendingChain.php +++ b/src/Illuminate/Foundation/Bus/PendingChain.php @@ -2,14 +2,19 @@ namespace Illuminate\Foundation\Bus; +use Closure; +use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Queue\CallQueuedClosure; +use Illuminate\Queue\SerializableClosureFactory; + class PendingChain { /** * The class name of the job being dispatched. * - * @var string + * @var mixed */ - public $class; + public $job; /** * The jobs to be chained. @@ -18,19 +23,111 @@ class PendingChain */ public $chain; + /** + * The name of the connection the chain should be sent to. + * + * @var string|null + */ + public $connection; + + /** + * The name of the queue the chain should be sent to. + * + * @var string|null + */ + public $queue; + + /** + * The number of seconds before the chain should be made available. + * + * @var \DateTimeInterface|\DateInterval|int|null + */ + public $delay; + + /** + * The callbacks to be executed on failure. + * + * @var array + */ + public $catchCallbacks = []; + /** * Create a new PendingChain instance. * - * @param string $class + * @param mixed $job * @param array $chain * @return void */ - public function __construct($class, $chain) + public function __construct($job, $chain) { - $this->class = $class; + $this->job = $job; $this->chain = $chain; } + /** + * Set the desired connection for the job. + * + * @param string|null $connection + * @return $this + */ + public function onConnection($connection) + { + $this->connection = $connection; + + return $this; + } + + /** + * Set the desired queue for the job. + * + * @param string|null $queue + * @return $this + */ + public function onQueue($queue) + { + $this->queue = $queue; + + return $this; + } + + /** + * Set the desired delay for the chain. + * + * @param \DateTimeInterface|\DateInterval|int|null $delay + * @return $this + */ + public function delay($delay) + { + $this->delay = $delay; + + return $this; + } + + /** + * Add a callback to be executed on job failure. + * + * @param callable $callback + * @return $this + */ + public function catch($callback) + { + $this->catchCallbacks[] = $callback instanceof Closure + ? SerializableClosureFactory::make($callback) + : $callback; + + return $this; + } + + /** + * Get the "catch" callbacks that have been registered. + * + * @return array + */ + public function catchCallbacks() + { + return $this->catchCallbacks ?? []; + } + /** * Dispatch the job with the given arguments. * @@ -38,8 +135,31 @@ class PendingChain */ public function dispatch() { - return (new PendingDispatch( - new $this->class(...func_get_args()) - ))->chain($this->chain); + if (is_string($this->job)) { + $firstJob = new $this->job(...func_get_args()); + } elseif ($this->job instanceof Closure) { + $firstJob = CallQueuedClosure::create($this->job); + } else { + $firstJob = $this->job; + } + + if ($this->connection) { + $firstJob->chainConnection = $this->connection; + $firstJob->connection = $firstJob->connection ?: $this->connection; + } + + if ($this->queue) { + $firstJob->chainQueue = $this->queue; + $firstJob->queue = $firstJob->queue ?: $this->queue; + } + + if ($this->delay) { + $firstJob->delay = ! is_null($firstJob->delay) ? $firstJob->delay : $this->delay; + } + + $firstJob->chain($this->chain); + $firstJob->chainCatchCallbacks = $this->catchCallbacks(); + + return app(Dispatcher::class)->dispatch($firstJob); } } diff --git a/src/Illuminate/Foundation/Bus/PendingClosureDispatch.php b/src/Illuminate/Foundation/Bus/PendingClosureDispatch.php new file mode 100644 index 0000000000000000000000000000000000000000..84cc14a7788c8aad41089a10431a60cd6b117b8b --- /dev/null +++ b/src/Illuminate/Foundation/Bus/PendingClosureDispatch.php @@ -0,0 +1,21 @@ +<?php + +namespace Illuminate\Foundation\Bus; + +use Closure; + +class PendingClosureDispatch extends PendingDispatch +{ + /** + * Add a callback to be executed if the job fails. + * + * @param \Closure $callback + * @return $this + */ + public function catch(Closure $callback) + { + $this->job->onFailure($callback); + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Bus/PendingDispatch.php b/src/Illuminate/Foundation/Bus/PendingDispatch.php index 3c3879d4fb981957fe0b29182a2cf9fcb35e18d6..76c16db624db0b1ae002ece461c1bba7f33e0ea9 100644 --- a/src/Illuminate/Foundation/Bus/PendingDispatch.php +++ b/src/Illuminate/Foundation/Bus/PendingDispatch.php @@ -2,7 +2,11 @@ namespace Illuminate\Foundation\Bus; +use Illuminate\Bus\UniqueLock; +use Illuminate\Container\Container; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Queue\ShouldBeUnique; class PendingDispatch { @@ -13,6 +17,13 @@ class PendingDispatch */ protected $job; + /** + * Indicates if the job should be dispatched immediately after sending the response. + * + * @var bool + */ + protected $afterResponse = false; + /** * Create a new pending job dispatch. * @@ -89,6 +100,30 @@ class PendingDispatch return $this; } + /** + * Indicate that the job should be dispatched after all database transactions have committed. + * + * @return $this + */ + public function afterCommit() + { + $this->job->afterCommit(); + + return $this; + } + + /** + * Indicate that the job should not wait until database transactions have been committed before dispatching. + * + * @return $this + */ + public function beforeCommit() + { + $this->job->beforeCommit(); + + return $this; + } + /** * Set the jobs that should run if this job is successful. * @@ -102,6 +137,47 @@ class PendingDispatch return $this; } + /** + * Indicate that the job should be dispatched after the response is sent to the browser. + * + * @return $this + */ + public function afterResponse() + { + $this->afterResponse = true; + + return $this; + } + + /** + * Determine if the job should be dispatched. + * + * @return bool + */ + protected function shouldDispatch() + { + if (! $this->job instanceof ShouldBeUnique) { + return true; + } + + return (new UniqueLock(Container::getInstance()->make(Cache::class))) + ->acquire($this->job); + } + + /** + * Dynamically proxy methods to the underlying job. + * + * @param string $method + * @param array $parameters + * @return $this + */ + public function __call($method, $parameters) + { + $this->job->{$method}(...$parameters); + + return $this; + } + /** * Handle the object's destruction. * @@ -109,6 +185,12 @@ class PendingDispatch */ public function __destruct() { - app(Dispatcher::class)->dispatch($this->job); + if (! $this->shouldDispatch()) { + return; + } elseif ($this->afterResponse) { + app(Dispatcher::class)->dispatchAfterResponse($this->job); + } else { + app(Dispatcher::class)->dispatch($this->job); + } } } diff --git a/src/Illuminate/Foundation/ComposerScripts.php b/src/Illuminate/Foundation/ComposerScripts.php index fcda187fd2617a74ab899e6b96e51b74e137a08a..8cf5684091381f84925795034c9fcf2641aa3d6a 100644 --- a/src/Illuminate/Foundation/ComposerScripts.php +++ b/src/Illuminate/Foundation/ComposerScripts.php @@ -54,11 +54,15 @@ class ComposerScripts { $laravel = new Application(getcwd()); - if (file_exists($servicesPath = $laravel->getCachedServicesPath())) { + if (is_file($configPath = $laravel->getCachedConfigPath())) { + @unlink($configPath); + } + + if (is_file($servicesPath = $laravel->getCachedServicesPath())) { @unlink($servicesPath); } - if (file_exists($packagesPath = $laravel->getCachedPackagesPath())) { + if (is_file($packagesPath = $laravel->getCachedPackagesPath())) { @unlink($packagesPath); } } diff --git a/src/Illuminate/Foundation/Console/CastMakeCommand.php b/src/Illuminate/Foundation/Console/CastMakeCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..3fa3a667fff3c425a58fc5dfebad1b6e704f01f1 --- /dev/null +++ b/src/Illuminate/Foundation/Console/CastMakeCommand.php @@ -0,0 +1,63 @@ +<?php + +namespace Illuminate\Foundation\Console; + +use Illuminate\Console\GeneratorCommand; + +class CastMakeCommand extends GeneratorCommand +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'make:cast'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Create a new custom Eloquent cast class'; + + /** + * The type of class being generated. + * + * @var string + */ + protected $type = 'Cast'; + + /** + * Get the stub file for the generator. + * + * @return string + */ + protected function getStub() + { + return $this->resolveStubPath('/stubs/cast.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\Casts'; + } +} diff --git a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php index 202d81cfd685000a56434029678ab40476f85aad..756fce6de31920fdace60b20003daf4241cc9565 100644 --- a/src/Illuminate/Foundation/Console/ChannelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ChannelMakeCommand.php @@ -36,7 +36,7 @@ class ChannelMakeCommand extends GeneratorCommand protected function buildClass($name) { return str_replace( - 'DummyUser', + ['DummyUser', '{{ userModel }}'], class_basename($this->userProviderModel()), parent::buildClass($name) ); diff --git a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php index 399a44dc543600da73c98e7c3af1aba2a6514586..87ea044b823a109b4190810eb06720cbc0b1fa32 100644 --- a/src/Illuminate/Foundation/Console/ClearCompiledCommand.php +++ b/src/Illuminate/Foundation/Console/ClearCompiledCommand.php @@ -27,11 +27,11 @@ class ClearCompiledCommand extends Command */ public function handle() { - if (file_exists($servicesPath = $this->laravel->getCachedServicesPath())) { + if (is_file($servicesPath = $this->laravel->getCachedServicesPath())) { @unlink($servicesPath); } - if (file_exists($packagesPath = $this->laravel->getCachedPackagesPath())) { + if (is_file($packagesPath = $this->laravel->getCachedPackagesPath())) { @unlink($packagesPath); } diff --git a/src/Illuminate/Foundation/Console/ClosureCommand.php b/src/Illuminate/Foundation/Console/ClosureCommand.php index c0d736bdb3daea28c2134771c7c2aaa41802d7b7..4cd54e8e4a793fa35e9d6b3322544a50b3dbfd07 100644 --- a/src/Illuminate/Foundation/Console/ClosureCommand.php +++ b/src/Illuminate/Foundation/Console/ClosureCommand.php @@ -37,7 +37,7 @@ class ClosureCommand extends Command * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return mixed + * @return int */ protected function execute(InputInterface $input, OutputInterface $output) { @@ -51,11 +51,22 @@ class ClosureCommand extends Command } } - return $this->laravel->call( + return (int) $this->laravel->call( $this->callback->bindTo($this, $this), $parameters ); } + /** + * Set the description for the command. + * + * @param string $description + * @return $this + */ + public function purpose($description) + { + return $this->describe($description); + } + /** * Set the description for the command. * diff --git a/src/Illuminate/Foundation/Console/ComponentMakeCommand.php b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..b0a3344180b4d5780ebb213774862f27ad5d2a9d --- /dev/null +++ b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php @@ -0,0 +1,163 @@ +<?php + +namespace Illuminate\Foundation\Console; + +use Illuminate\Console\GeneratorCommand; +use Illuminate\Foundation\Inspiring; +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputOption; + +class ComponentMakeCommand extends GeneratorCommand +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'make:component'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Create a new view component class'; + + /** + * The type of class being generated. + * + * @var string + */ + protected $type = 'Component'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + if (parent::handle() === false && ! $this->option('force')) { + return false; + } + + if (! $this->option('inline')) { + $this->writeView(); + } + } + + /** + * Write the view for the component. + * + * @return void + */ + protected function writeView() + { + $path = $this->viewPath( + str_replace('.', '/', 'components.'.$this->getView()).'.blade.php' + ); + + if (! $this->files->isDirectory(dirname($path))) { + $this->files->makeDirectory(dirname($path), 0777, true, true); + } + + if ($this->files->exists($path) && ! $this->option('force')) { + $this->error('View already exists!'); + + return; + } + + file_put_contents( + $path, + '<div> + <!-- '.Inspiring::quote().' --> +</div>' + ); + } + + /** + * Build the class with the given name. + * + * @param string $name + * @return string + */ + protected function buildClass($name) + { + if ($this->option('inline')) { + return str_replace( + ['DummyView', '{{ view }}'], + "<<<'blade'\n<div>\n <!-- ".Inspiring::quote()." -->\n</div>\nblade", + parent::buildClass($name) + ); + } + + return str_replace( + ['DummyView', '{{ view }}'], + 'view(\'components.'.$this->getView().'\')', + parent::buildClass($name) + ); + } + + /** + * Get the view name relative to the components directory. + * + * @return string view + */ + protected function getView() + { + $name = str_replace('\\', '/', $this->argument('name')); + + return collect(explode('/', $name)) + ->map(function ($part) { + return Str::kebab($part); + }) + ->implode('.'); + } + + /** + * Get the stub file for the generator. + * + * @return string + */ + protected function getStub() + { + return $this->resolveStubPath('/stubs/view-component.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\View\Components'; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', null, InputOption::VALUE_NONE, 'Create the class even if the component already exists'], + ['inline', null, InputOption::VALUE_NONE, 'Create a component that renders an inline view'], + ]; + } +} diff --git a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php index 0d2ab62e22912c2f42a4a06c8d31979f788bb12b..90607c77d194f5f2fad8c704e618d11bdf31f1a4 100644 --- a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php @@ -2,12 +2,15 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; class ConsoleMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -40,7 +43,7 @@ class ConsoleMakeCommand extends GeneratorCommand { $stub = parent::replaceClass($stub, $name); - return str_replace('dummy:command', $this->option('command'), $stub); + return str_replace(['dummy:command', '{{ command }}'], $this->option('command'), $stub); } /** @@ -50,7 +53,11 @@ class ConsoleMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/console.stub'; + $relativePath = '/stubs/console.stub'; + + return file_exists($customPath = $this->laravel->basePath(trim($relativePath, '/'))) + ? $customPath + : __DIR__.$relativePath; } /** diff --git a/src/Illuminate/Foundation/Console/DownCommand.php b/src/Illuminate/Foundation/Console/DownCommand.php index af2f6eb95beefeb90ceb9f953ebef4a3239a53f2..676715af4701ae469ce05a3b044afb64233b6eee 100644 --- a/src/Illuminate/Foundation/Console/DownCommand.php +++ b/src/Illuminate/Foundation/Console/DownCommand.php @@ -2,29 +2,33 @@ namespace Illuminate\Foundation\Console; +use App\Http\Middleware\PreventRequestsDuringMaintenance; use Exception; use Illuminate\Console\Command; -use Illuminate\Support\InteractsWithTime; +use Illuminate\Foundation\Events\MaintenanceModeEnabled; +use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths; +use Throwable; class DownCommand extends Command { - use InteractsWithTime; - /** * The console command signature. * * @var string */ - protected $signature = 'down {--message= : The message for the maintenance mode} + protected $signature = 'down {--redirect= : The path that users should be redirected to} + {--render= : The view that should be prerendered for display during maintenance mode} {--retry= : The number of seconds after which the request may be retried} - {--allow=* : IP or networks allowed to access the application while in maintenance mode}'; + {--refresh= : The number of seconds after which the browser may refresh} + {--secret= : The secret phrase that may be used to bypass maintenance mode} + {--status=503 : The status code that should be used when returning the maintenance mode response}'; /** * The console command description. * * @var string */ - protected $description = 'Put the application into maintenance mode'; + protected $description = 'Put the application into maintenance / demo mode'; /** * Execute the console command. @@ -34,15 +38,23 @@ class DownCommand extends Command public function handle() { try { - if (file_exists(storage_path('framework/down'))) { + if (is_file(storage_path('framework/down'))) { $this->comment('Application is already down.'); - return true; + return 0; } - file_put_contents(storage_path('framework/down'), - json_encode($this->getDownFilePayload(), - JSON_PRETTY_PRINT)); + file_put_contents( + storage_path('framework/down'), + json_encode($this->getDownFilePayload(), JSON_PRETTY_PRINT) + ); + + file_put_contents( + storage_path('framework/maintenance.php'), + file_get_contents(__DIR__.'/stubs/maintenance-mode.stub') + ); + + $this->laravel->get('events')->dispatch(MaintenanceModeEnabled::class); $this->comment('Application is now in maintenance mode.'); } catch (Exception $e) { @@ -62,13 +74,58 @@ class DownCommand extends Command protected function getDownFilePayload() { return [ - 'time' => $this->currentTime(), - 'message' => $this->option('message'), + 'except' => $this->excludedPaths(), + 'redirect' => $this->redirectPath(), 'retry' => $this->getRetryTime(), - 'allowed' => $this->option('allow'), + 'refresh' => $this->option('refresh'), + 'secret' => $this->option('secret'), + 'status' => (int) $this->option('status', 503), + 'template' => $this->option('render') ? $this->prerenderView() : null, ]; } + /** + * Get the paths that should be excluded from maintenance mode. + * + * @return array + */ + protected function excludedPaths() + { + try { + return $this->laravel->make(PreventRequestsDuringMaintenance::class)->getExcludedPaths(); + } catch (Throwable $e) { + return []; + } + } + + /** + * Get the path that users should be redirected to. + * + * @return string + */ + protected function redirectPath() + { + if ($this->option('redirect') && $this->option('redirect') !== '/') { + return '/'.trim($this->option('redirect'), '/'); + } + + return $this->option('redirect'); + } + + /** + * Prerender the specified view so that it can be rendered even before loading Composer. + * + * @return string + */ + protected function prerenderView() + { + (new RegisterErrorViewPaths)(); + + return view($this->option('render'), [ + 'retryAfter' => $this->option('retry'), + ])->render(); + } + /** * Get the number of seconds the client should wait before retrying their request. * diff --git a/src/Illuminate/Foundation/Console/EventMakeCommand.php b/src/Illuminate/Foundation/Console/EventMakeCommand.php index f18719aa93fafd6ebed094aeedab299162fa9a6e..632be4b657be59e89f4c8fb464f1b1d21a1828a1 100644 --- a/src/Illuminate/Foundation/Console/EventMakeCommand.php +++ b/src/Illuminate/Foundation/Console/EventMakeCommand.php @@ -35,7 +35,8 @@ class EventMakeCommand extends GeneratorCommand */ protected function alreadyExists($rawName) { - return class_exists($rawName); + return class_exists($rawName) || + $this->files->exists($this->getPath($this->qualifyClass($rawName))); } /** @@ -45,7 +46,20 @@ class EventMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/event.stub'; + return $this->resolveStubPath('/stubs/event.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/JobMakeCommand.php b/src/Illuminate/Foundation/Console/JobMakeCommand.php index 60d942eb0f276d5734c15da22e69cd3903bf8721..bec3d9d10315889753e4904680da24262b5f0f55 100644 --- a/src/Illuminate/Foundation/Console/JobMakeCommand.php +++ b/src/Illuminate/Foundation/Console/JobMakeCommand.php @@ -2,11 +2,14 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Input\InputOption; class JobMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -36,8 +39,21 @@ class JobMakeCommand extends GeneratorCommand protected function getStub() { return $this->option('sync') - ? __DIR__.'/stubs/job.stub' - : __DIR__.'/stubs/job-queued.stub'; + ? $this->resolveStubPath('/stubs/job.stub') + : $this->resolveStubPath('/stubs/job.queued.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 058ee7c8eeda0a1726e30760d11df4da5279277f..cdfaeaf3a541ccf38258fe7c08ffca3bf8bd242f 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -3,7 +3,6 @@ namespace Illuminate\Foundation\Console; use Closure; -use Exception; use Illuminate\Console\Application as Artisan; use Illuminate\Console\Command; use Illuminate\Console\Scheduling\Schedule; @@ -15,7 +14,6 @@ use Illuminate\Support\Arr; use Illuminate\Support\Env; use Illuminate\Support\Str; use ReflectionClass; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Symfony\Component\Finder\Finder; use Throwable; @@ -59,7 +57,7 @@ class Kernel implements KernelContract /** * The bootstrap classes for the application. * - * @var array + * @var string[] */ protected $bootstrappers = [ \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class, @@ -113,7 +111,7 @@ class Kernel implements KernelContract */ protected function scheduleCache() { - return Env::get('SCHEDULE_CACHE_DRIVER'); + return $this->app['config']->get('cache.schedule_store', Env::get('SCHEDULE_CACHE_DRIVER')); } /** @@ -129,15 +127,7 @@ class Kernel implements KernelContract $this->bootstrap(); return $this->getArtisan()->run($input, $output); - } catch (Exception $e) { - $this->reportException($e); - - $this->renderException($output, $e); - - return 1; } catch (Throwable $e) { - $e = new FatalThrowableError($e); - $this->reportException($e); $this->renderException($output, $e); @@ -182,7 +172,7 @@ class Kernel implements KernelContract } /** - * Register the Closure based commands for the application. + * Register the commands for the application. * * @return void */ @@ -233,7 +223,7 @@ class Kernel implements KernelContract $command = $namespace.str_replace( ['/', '.php'], ['\\', ''], - Str::after($command->getPathname(), realpath(app_path()).DIRECTORY_SEPARATOR) + Str::after($command->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) ); if (is_subclass_of($command, Command::class) && @@ -368,10 +358,10 @@ class Kernel implements KernelContract /** * Report the exception to the exception handler. * - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function reportException(Exception $e) + protected function reportException(Throwable $e) { $this->app[ExceptionHandler::class]->report($e); } @@ -380,10 +370,10 @@ class Kernel implements KernelContract * Render the given exception. * * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function renderException($output, Exception $e) + protected function renderException($output, Throwable $e) { $this->app[ExceptionHandler::class]->renderForConsole($output, $e); } diff --git a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php index 0ded743aa7c705726d533351f9823662bcc3573f..b27e7986336e71360ea3cd081fab6f8ec204fe0b 100644 --- a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php @@ -2,12 +2,15 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; class ListenerMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -44,15 +47,15 @@ class ListenerMakeCommand extends GeneratorCommand 'Illuminate', '\\', ])) { - $event = $this->laravel->getNamespace().'Events\\'.$event; + $event = $this->laravel->getNamespace().'Events\\'.str_replace('/', '\\', $event); } $stub = str_replace( - 'DummyEvent', class_basename($event), parent::buildClass($name) + ['DummyEvent', '{{ event }}'], class_basename($event), parent::buildClass($name) ); return str_replace( - 'DummyFullEvent', trim($event, '\\'), $stub + ['DummyFullEvent', '{{ eventNamespace }}'], trim($event, '\\'), $stub ); } diff --git a/src/Illuminate/Foundation/Console/MailMakeCommand.php b/src/Illuminate/Foundation/Console/MailMakeCommand.php index d401a9ec45bee0b9f8ac6966cde2a0ad2bdc0373..e32e2e20fa1b91684c0c7e4642ec14a83ca85778 100644 --- a/src/Illuminate/Foundation/Console/MailMakeCommand.php +++ b/src/Illuminate/Foundation/Console/MailMakeCommand.php @@ -2,11 +2,15 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; +use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; class MailMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -39,7 +43,7 @@ class MailMakeCommand extends GeneratorCommand return; } - if ($this->option('markdown')) { + if ($this->option('markdown') !== false) { $this->writeMarkdownTemplate(); } } @@ -51,7 +55,9 @@ class MailMakeCommand extends GeneratorCommand */ protected function writeMarkdownTemplate() { - $path = resource_path('views/'.str_replace('.', '/', $this->option('markdown'))).'.blade.php'; + $path = $this->viewPath( + str_replace('.', '/', $this->getView()).'.blade.php' + ); if (! $this->files->isDirectory(dirname($path))) { $this->files->makeDirectory(dirname($path), 0755, true); @@ -70,13 +76,29 @@ class MailMakeCommand extends GeneratorCommand { $class = parent::buildClass($name); - if ($this->option('markdown')) { - $class = str_replace('DummyView', $this->option('markdown'), $class); + if ($this->option('markdown') !== false) { + $class = str_replace(['DummyView', '{{ view }}'], $this->getView(), $class); } return $class; } + /** + * Get the view name. + * + * @return string + */ + protected function getView() + { + $view = $this->option('markdown'); + + if (! $view) { + $view = 'mail.'.Str::kebab(class_basename($this->argument('name'))); + } + + return $view; + } + /** * Get the stub file for the generator. * @@ -84,9 +106,23 @@ class MailMakeCommand extends GeneratorCommand */ protected function getStub() { - return $this->option('markdown') - ? __DIR__.'/stubs/markdown-mail.stub' - : __DIR__.'/stubs/mail.stub'; + return $this->resolveStubPath( + $this->option('markdown') !== false + ? '/stubs/markdown-mail.stub' + : '/stubs/mail.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -110,7 +146,7 @@ class MailMakeCommand extends GeneratorCommand return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the mailable already exists'], - ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the mailable'], + ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the mailable', false], ]; } } diff --git a/src/Illuminate/Foundation/Console/ModelMakeCommand.php b/src/Illuminate/Foundation/Console/ModelMakeCommand.php index 0d2d0b938f23af387652ade2754d553ead3bdccf..4f03aae01c2ae11166835b4944f3df172ea2e11f 100644 --- a/src/Illuminate/Foundation/Console/ModelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ModelMakeCommand.php @@ -2,12 +2,15 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; class ModelMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -45,6 +48,7 @@ class ModelMakeCommand extends GeneratorCommand $this->input->setOption('seed', true); $this->input->setOption('migration', true); $this->input->setOption('controller', true); + $this->input->setOption('policy', true); $this->input->setOption('resource', true); } @@ -63,6 +67,10 @@ class ModelMakeCommand extends GeneratorCommand if ($this->option('controller') || $this->option('resource') || $this->option('api')) { $this->createController(); } + + if ($this->option('policy')) { + $this->createPolicy(); + } } /** @@ -72,7 +80,7 @@ class ModelMakeCommand extends GeneratorCommand */ protected function createFactory() { - $factory = Str::studly(class_basename($this->argument('name'))); + $factory = Str::studly($this->argument('name')); $this->call('make:factory', [ 'name' => "{$factory}Factory", @@ -108,7 +116,7 @@ class ModelMakeCommand extends GeneratorCommand { $seeder = Str::studly(class_basename($this->argument('name'))); - $this->call('make:seed', [ + $this->call('make:seeder', [ 'name' => "{$seeder}Seeder", ]); } @@ -125,12 +133,28 @@ class ModelMakeCommand extends GeneratorCommand $modelName = $this->qualifyClass($this->getNameInput()); $this->call('make:controller', array_filter([ - 'name' => "{$controller}Controller", + 'name' => "{$controller}Controller", '--model' => $this->option('resource') || $this->option('api') ? $modelName : null, '--api' => $this->option('api'), + '--requests' => $this->option('requests') || $this->option('all'), ])); } + /** + * Create a policy file for the model. + * + * @return void + */ + protected function createPolicy() + { + $policy = Str::studly(class_basename($this->argument('name'))); + + $this->call('make:policy', [ + 'name' => "{$policy}Policy", + '--model' => $this->qualifyClass($this->getNameInput()), + ]); + } + /** * Get the stub file for the generator. * @@ -138,11 +162,33 @@ class ModelMakeCommand extends GeneratorCommand */ protected function getStub() { - if ($this->option('pivot')) { - return __DIR__.'/stubs/pivot.model.stub'; - } + return $this->option('pivot') + ? $this->resolveStubPath('/stubs/model.pivot.stub') + : $this->resolveStubPath('/stubs/model.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } - return __DIR__.'/stubs/model.stub'; + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return is_dir(app_path('Models')) ? $rootNamespace.'\\Models' : $rootNamespace; } /** @@ -153,15 +199,17 @@ class ModelMakeCommand extends GeneratorCommand protected function getOptions() { return [ - ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, seeder, factory, and resource controller for the model'], + ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, seeder, factory, policy, and resource controller for the model'], ['controller', 'c', InputOption::VALUE_NONE, 'Create a new controller for the model'], ['factory', 'f', InputOption::VALUE_NONE, 'Create a new factory for the model'], ['force', null, InputOption::VALUE_NONE, 'Create the class even if the model already exists'], ['migration', 'm', InputOption::VALUE_NONE, 'Create a new migration file for the model'], - ['seed', 's', InputOption::VALUE_NONE, 'Create a new seeder file for the model'], + ['policy', null, InputOption::VALUE_NONE, 'Create a new policy for the model'], + ['seed', 's', InputOption::VALUE_NONE, 'Create a new seeder for the model'], ['pivot', 'p', InputOption::VALUE_NONE, 'Indicates if the generated model should be a custom intermediate table model'], ['resource', 'r', InputOption::VALUE_NONE, 'Indicates if the generated controller should be a resource controller'], ['api', null, InputOption::VALUE_NONE, 'Indicates if the generated controller should be an API controller'], + ['requests', 'R', InputOption::VALUE_NONE, 'Create new form request classes and use them in the resource controller'], ]; } } diff --git a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php index 40e9d849f3ad5d58e8bbc4f89940307be71fad10..f8a5bf8c884f909afeffeb3c96100611d1b34b8c 100644 --- a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php +++ b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php @@ -2,11 +2,14 @@ namespace Illuminate\Foundation\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Input\InputOption; class NotificationMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -51,7 +54,9 @@ class NotificationMakeCommand extends GeneratorCommand */ protected function writeMarkdownTemplate() { - $path = resource_path('views/'.str_replace('.', '/', $this->option('markdown'))).'.blade.php'; + $path = $this->viewPath( + str_replace('.', '/', $this->option('markdown')).'.blade.php' + ); if (! $this->files->isDirectory(dirname($path))) { $this->files->makeDirectory(dirname($path), 0755, true); @@ -71,7 +76,7 @@ class NotificationMakeCommand extends GeneratorCommand $class = parent::buildClass($name); if ($this->option('markdown')) { - $class = str_replace('DummyView', $this->option('markdown'), $class); + $class = str_replace(['DummyView', '{{ view }}'], $this->option('markdown'), $class); } return $class; @@ -85,8 +90,21 @@ class NotificationMakeCommand extends GeneratorCommand protected function getStub() { return $this->option('markdown') - ? __DIR__.'/stubs/markdown-notification.stub' - : __DIR__.'/stubs/notification.stub'; + ? $this->resolveStubPath('/stubs/markdown-notification.stub') + : $this->resolveStubPath('/stubs/notification.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php index b1f1346a58b6e2fac6654db8515dd83881c30870..a2661f3fabdeaea8c287dbcc1384e5d1af5d2440 100644 --- a/src/Illuminate/Foundation/Console/ObserverMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ObserverMakeCommand.php @@ -3,7 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\GeneratorCommand; -use Illuminate\Support\Str; +use InvalidArgumentException; use Symfony\Component\Console\Input\InputOption; class ObserverMakeCommand extends GeneratorCommand @@ -45,47 +45,73 @@ class ObserverMakeCommand extends GeneratorCommand } /** - * Get the stub file for the generator. + * Replace the model for the given stub. * + * @param string $stub + * @param string $model * @return string */ - protected function getStub() + protected function replaceModel($stub, $model) { - return $this->option('model') - ? __DIR__.'/stubs/observer.stub' - : __DIR__.'/stubs/observer.plain.stub'; + $modelClass = $this->parseModel($model); + + $replace = [ + 'DummyFullModelClass' => $modelClass, + '{{ namespacedModel }}' => $modelClass, + '{{namespacedModel}}' => $modelClass, + 'DummyModelClass' => class_basename($modelClass), + '{{ model }}' => class_basename($modelClass), + '{{model}}' => class_basename($modelClass), + 'DummyModelVariable' => lcfirst(class_basename($modelClass)), + '{{ modelVariable }}' => lcfirst(class_basename($modelClass)), + '{{modelVariable}}' => lcfirst(class_basename($modelClass)), + ]; + + return str_replace( + array_keys($replace), array_values($replace), $stub + ); } /** - * Replace the model for the given stub. + * Get the fully-qualified model class name. * - * @param string $stub * @param string $model * @return string + * + * @throws \InvalidArgumentException */ - protected function replaceModel($stub, $model) + protected function parseModel($model) { - $model = str_replace('/', '\\', $model); - - $namespaceModel = $this->laravel->getNamespace().$model; - - if (Str::startsWith($model, '\\')) { - $stub = str_replace('NamespacedDummyModel', trim($model, '\\'), $stub); - } else { - $stub = str_replace('NamespacedDummyModel', $namespaceModel, $stub); + if (preg_match('([^A-Za-z0-9_/\\\\])', $model)) { + throw new InvalidArgumentException('Model name contains invalid characters.'); } - $stub = str_replace( - "use {$namespaceModel};\nuse {$namespaceModel};", "use {$namespaceModel};", $stub - ); - - $model = class_basename(trim($model, '\\')); - - $stub = str_replace('DocDummyModel', Str::snake($model, ' '), $stub); + return $this->qualifyModel($model); + } - $stub = str_replace('DummyModel', $model, $stub); + /** + * Get the stub file for the generator. + * + * @return string + */ + protected function getStub() + { + return $this->option('model') + ? $this->resolveStubPath('/stubs/observer.stub') + : $this->resolveStubPath('/stubs/observer.plain.stub'); + } - return str_replace('dummyModel', Str::camel($model), $stub); + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 0bd92dfee368a3639ea14a1193f8a590b8278b27..7506cc26a493577223e93a8af6c0439b2eb6d91d 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -27,6 +27,7 @@ class OptimizeClearCommand extends Command */ public function handle() { + $this->call('event:clear'); $this->call('view:clear'); $this->call('cache:clear'); $this->call('route:clear'); diff --git a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php index adc04d3c80a4b5b248771a6403c37db438b9b810..aeb9590925334ed84a675b96739f3db39a582f73 100644 --- a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php +++ b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php @@ -4,6 +4,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use LogicException; use Symfony\Component\Console\Input\InputOption; class PolicyMakeCommand extends GeneratorCommand @@ -67,6 +68,32 @@ class PolicyMakeCommand extends GeneratorCommand ); } + /** + * Get the model for the guard's user provider. + * + * @return string|null + * + * @throws \LogicException + */ + protected function userProviderModel() + { + $config = $this->laravel['config']; + + $guard = $this->option('guard') ?: $config->get('auth.defaults.guard'); + + if (is_null($guardProvider = $config->get('auth.guards.'.$guard.'.provider'))) { + throw new LogicException('The ['.$guard.'] guard is not defined in your "auth" configuration file.'); + } + + if (! $config->get('auth.providers.'.$guardProvider.'.model')) { + return 'App\\Models\\User'; + } + + return $config->get( + 'auth.providers.'.$guardProvider.'.model' + ); + } + /** * Replace the model for the given stub. * @@ -78,33 +105,46 @@ class PolicyMakeCommand extends GeneratorCommand { $model = str_replace('/', '\\', $model); - $namespaceModel = $this->laravel->getNamespace().$model; - if (Str::startsWith($model, '\\')) { - $stub = str_replace('NamespacedDummyModel', trim($model, '\\'), $stub); + $namespacedModel = trim($model, '\\'); } else { - $stub = str_replace('NamespacedDummyModel', $namespaceModel, $stub); + $namespacedModel = $this->qualifyModel($model); } - $stub = str_replace( - "use {$namespaceModel};\nuse {$namespaceModel};", "use {$namespaceModel};", $stub - ); - $model = class_basename(trim($model, '\\')); $dummyUser = class_basename($this->userProviderModel()); $dummyModel = Str::camel($model) === 'user' ? 'model' : $model; - $stub = str_replace('DocDummyModel', Str::snake($dummyModel, ' '), $stub); - - $stub = str_replace('DummyModel', $model, $stub); - - $stub = str_replace('dummyModel', Str::camel($dummyModel), $stub); + $replace = [ + 'NamespacedDummyModel' => $namespacedModel, + '{{ namespacedModel }}' => $namespacedModel, + '{{namespacedModel}}' => $namespacedModel, + 'DummyModel' => $model, + '{{ model }}' => $model, + '{{model}}' => $model, + 'dummyModel' => Str::camel($dummyModel), + '{{ modelVariable }}' => Str::camel($dummyModel), + '{{modelVariable}}' => Str::camel($dummyModel), + 'DummyUser' => $dummyUser, + '{{ user }}' => $dummyUser, + '{{user}}' => $dummyUser, + '$user' => '$'.Str::camel($dummyUser), + ]; - $stub = str_replace('DummyUser', $dummyUser, $stub); + $stub = str_replace( + array_keys($replace), array_values($replace), $stub + ); - return str_replace('DocDummyPluralModel', Str::snake(Str::pluralStudly($dummyModel), ' '), $stub); + return preg_replace( + vsprintf('/use %s;[\r\n]+use %s;/', [ + preg_quote($namespacedModel, '/'), + preg_quote($namespacedModel, '/'), + ]), + "use {$namespacedModel};", + $stub + ); } /** @@ -115,8 +155,21 @@ class PolicyMakeCommand extends GeneratorCommand protected function getStub() { return $this->option('model') - ? __DIR__.'/stubs/policy.stub' - : __DIR__.'/stubs/policy.plain.stub'; + ? $this->resolveStubPath('/stubs/policy.stub') + : $this->resolveStubPath('/stubs/policy.plain.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -139,6 +192,7 @@ class PolicyMakeCommand extends GeneratorCommand { return [ ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the policy applies to'], + ['guard', 'g', InputOption::VALUE_OPTIONAL, 'The guard that the policy relies on'], ]; } } diff --git a/src/Illuminate/Foundation/Console/PresetCommand.php b/src/Illuminate/Foundation/Console/PresetCommand.php deleted file mode 100644 index 6473d154bcc3f82e856ff51ecf4c4c1e00ba771c..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/PresetCommand.php +++ /dev/null @@ -1,96 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console; - -use Illuminate\Console\Command; -use InvalidArgumentException; - -class PresetCommand extends Command -{ - /** - * The console command signature. - * - * @var string - */ - protected $signature = 'preset - { type : The preset type (none, bootstrap, vue, react) } - { --option=* : Pass an option to the preset command }'; - - /** - * The console command description. - * - * @var string - */ - protected $description = 'Swap the front-end scaffolding for the application'; - - /** - * Execute the console command. - * - * @return void - * - * @throws \InvalidArgumentException - */ - public function handle() - { - if (static::hasMacro($this->argument('type'))) { - return call_user_func(static::$macros[$this->argument('type')], $this); - } - - if (! in_array($this->argument('type'), ['none', 'bootstrap', 'vue', 'react'])) { - throw new InvalidArgumentException('Invalid preset.'); - } - - return $this->{$this->argument('type')}(); - } - - /** - * Install the "fresh" preset. - * - * @return void - */ - protected function none() - { - Presets\None::install(); - - $this->info('Frontend scaffolding removed successfully.'); - } - - /** - * Install the "bootstrap" preset. - * - * @return void - */ - protected function bootstrap() - { - Presets\Bootstrap::install(); - - $this->info('Bootstrap scaffolding installed successfully.'); - $this->comment('Please run "npm install && npm run dev" to compile your fresh scaffolding.'); - } - - /** - * Install the "vue" preset. - * - * @return void - */ - protected function vue() - { - Presets\Vue::install(); - - $this->info('Vue scaffolding installed successfully.'); - $this->comment('Please run "npm install && npm run dev" to compile your fresh scaffolding.'); - } - - /** - * Install the "react" preset. - * - * @return void - */ - protected function react() - { - Presets\React::install(); - - $this->info('React scaffolding installed successfully.'); - $this->comment('Please run "npm install && npm run dev" to compile your fresh scaffolding.'); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/Bootstrap.php b/src/Illuminate/Foundation/Console/Presets/Bootstrap.php deleted file mode 100644 index 248e2f29d446b8a9c95725ceadc7e06065e34a06..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/Bootstrap.php +++ /dev/null @@ -1,44 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console\Presets; - -class Bootstrap extends Preset -{ - /** - * Install the preset. - * - * @return void - */ - public static function install() - { - static::updatePackages(); - static::updateSass(); - static::removeNodeModules(); - } - - /** - * Update the given package array. - * - * @param array $packages - * @return array - */ - protected static function updatePackageArray(array $packages) - { - return [ - 'bootstrap' => '^4.0.0', - 'jquery' => '^3.2', - 'popper.js' => '^1.12', - ] + $packages; - } - - /** - * Update the Sass files for the application. - * - * @return void - */ - protected static function updateSass() - { - copy(__DIR__.'/bootstrap-stubs/_variables.scss', resource_path('sass/_variables.scss')); - copy(__DIR__.'/bootstrap-stubs/app.scss', resource_path('sass/app.scss')); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/None.php b/src/Illuminate/Foundation/Console/Presets/None.php deleted file mode 100644 index 63e813e446625b690614feca8b27baa40b62aa03..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/None.php +++ /dev/null @@ -1,72 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console\Presets; - -use Illuminate\Filesystem\Filesystem; - -class None extends Preset -{ - /** - * Install the preset. - * - * @return void - */ - public static function install() - { - static::updatePackages(); - static::updateBootstrapping(); - static::updateWebpackConfiguration(); - - tap(new Filesystem, function ($filesystem) { - $filesystem->deleteDirectory(resource_path('js/components')); - $filesystem->delete(resource_path('sass/_variables.scss')); - $filesystem->deleteDirectory(base_path('node_modules')); - $filesystem->deleteDirectory(public_path('css')); - $filesystem->deleteDirectory(public_path('js')); - }); - } - - /** - * Update the given package array. - * - * @param array $packages - * @return array - */ - protected static function updatePackageArray(array $packages) - { - unset( - $packages['bootstrap'], - $packages['jquery'], - $packages['popper.js'], - $packages['vue'], - $packages['vue-template-compiler'], - $packages['@babel/preset-react'], - $packages['react'], - $packages['react-dom'] - ); - - return $packages; - } - - /** - * Write the stubs for the Sass and JavaScript files. - * - * @return void - */ - protected static function updateBootstrapping() - { - file_put_contents(resource_path('sass/app.scss'), ''.PHP_EOL); - copy(__DIR__.'/none-stubs/app.js', resource_path('js/app.js')); - copy(__DIR__.'/none-stubs/bootstrap.js', resource_path('js/bootstrap.js')); - } - - /** - * Update the Webpack configuration. - * - * @return void - */ - protected static function updateWebpackConfiguration() - { - copy(__DIR__.'/none-stubs/webpack.mix.js', base_path('webpack.mix.js')); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/Preset.php b/src/Illuminate/Foundation/Console/Presets/Preset.php deleted file mode 100644 index 99af6b6f457152741be1f0cace685379f366ab01..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/Preset.php +++ /dev/null @@ -1,64 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console\Presets; - -use Illuminate\Filesystem\Filesystem; - -class Preset -{ - /** - * Ensure the component directories we need exist. - * - * @return void - */ - protected static function ensureComponentDirectoryExists() - { - $filesystem = new Filesystem; - - if (! $filesystem->isDirectory($directory = resource_path('js/components'))) { - $filesystem->makeDirectory($directory, 0755, true); - } - } - - /** - * Update the "package.json" file. - * - * @param bool $dev - * @return void - */ - protected static function updatePackages($dev = true) - { - if (! file_exists(base_path('package.json'))) { - return; - } - - $configurationKey = $dev ? 'devDependencies' : 'dependencies'; - - $packages = json_decode(file_get_contents(base_path('package.json')), true); - - $packages[$configurationKey] = static::updatePackageArray( - array_key_exists($configurationKey, $packages) ? $packages[$configurationKey] : [] - ); - - ksort($packages[$configurationKey]); - - file_put_contents( - base_path('package.json'), - json_encode($packages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL - ); - } - - /** - * Remove the installed Node modules. - * - * @return void - */ - protected static function removeNodeModules() - { - tap(new Filesystem, function ($files) { - $files->deleteDirectory(base_path('node_modules')); - - $files->delete(base_path('yarn.lock')); - }); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/React.php b/src/Illuminate/Foundation/Console/Presets/React.php deleted file mode 100644 index a7871b386f48a204d328e8644aa17e04c9d52425..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/React.php +++ /dev/null @@ -1,76 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console\Presets; - -use Illuminate\Filesystem\Filesystem; -use Illuminate\Support\Arr; - -class React extends Preset -{ - /** - * Install the preset. - * - * @return void - */ - public static function install() - { - static::ensureComponentDirectoryExists(); - static::updatePackages(); - static::updateWebpackConfiguration(); - static::updateBootstrapping(); - static::updateComponent(); - static::removeNodeModules(); - } - - /** - * Update the given package array. - * - * @param array $packages - * @return array - */ - protected static function updatePackageArray(array $packages) - { - return [ - '@babel/preset-react' => '^7.0.0', - 'react' => '^16.2.0', - 'react-dom' => '^16.2.0', - ] + Arr::except($packages, ['vue', 'vue-template-compiler']); - } - - /** - * Update the Webpack configuration. - * - * @return void - */ - protected static function updateWebpackConfiguration() - { - copy(__DIR__.'/react-stubs/webpack.mix.js', base_path('webpack.mix.js')); - } - - /** - * Update the example component. - * - * @return void - */ - protected static function updateComponent() - { - (new Filesystem)->delete( - resource_path('js/components/ExampleComponent.vue') - ); - - copy( - __DIR__.'/react-stubs/Example.js', - resource_path('js/components/Example.js') - ); - } - - /** - * Update the bootstrapping files. - * - * @return void - */ - protected static function updateBootstrapping() - { - copy(__DIR__.'/react-stubs/app.js', resource_path('js/app.js')); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/Vue.php b/src/Illuminate/Foundation/Console/Presets/Vue.php deleted file mode 100644 index 9d6a966bd2731984c5308fdecb44c4b6df971a98..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/Vue.php +++ /dev/null @@ -1,76 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Console\Presets; - -use Illuminate\Filesystem\Filesystem; -use Illuminate\Support\Arr; - -class Vue extends Preset -{ - /** - * Install the preset. - * - * @return void - */ - public static function install() - { - static::ensureComponentDirectoryExists(); - static::updatePackages(); - static::updateWebpackConfiguration(); - static::updateBootstrapping(); - static::updateComponent(); - static::removeNodeModules(); - } - - /** - * Update the given package array. - * - * @param array $packages - * @return array - */ - protected static function updatePackageArray(array $packages) - { - return ['vue' => '^2.5.17'] + Arr::except($packages, [ - '@babel/preset-react', - 'react', - 'react-dom', - ]); - } - - /** - * Update the Webpack configuration. - * - * @return void - */ - protected static function updateWebpackConfiguration() - { - copy(__DIR__.'/vue-stubs/webpack.mix.js', base_path('webpack.mix.js')); - } - - /** - * Update the example component. - * - * @return void - */ - protected static function updateComponent() - { - (new Filesystem)->delete( - resource_path('js/components/Example.js') - ); - - copy( - __DIR__.'/vue-stubs/ExampleComponent.vue', - resource_path('js/components/ExampleComponent.vue') - ); - } - - /** - * Update the bootstrapping files. - * - * @return void - */ - protected static function updateBootstrapping() - { - copy(__DIR__.'/vue-stubs/app.js', resource_path('js/app.js')); - } -} diff --git a/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/_variables.scss b/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/_variables.scss deleted file mode 100644 index 0407ab577327b92faf6bbb6cb6391587d9db6d1a..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/_variables.scss +++ /dev/null @@ -1,19 +0,0 @@ -// Body -$body-bg: #f8fafc; - -// Typography -$font-family-sans-serif: 'Nunito', sans-serif; -$font-size-base: 0.9rem; -$line-height-base: 1.6; - -// Colors -$blue: #3490dc; -$indigo: #6574cd; -$purple: #9561e2; -$pink: #f66d9b; -$red: #e3342f; -$orange: #f6993f; -$yellow: #ffed4a; -$green: #38c172; -$teal: #4dc0b5; -$cyan: #6cb2eb; diff --git a/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/app.scss b/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/app.scss deleted file mode 100644 index 3f1850e3992d1e2e9deeacf1dabd4caf62ac7aea..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/bootstrap-stubs/app.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Fonts -@import url('https://fonts.googleapis.com/css?family=Nunito'); - -// Variables -@import 'variables'; - -// Bootstrap -@import '~bootstrap/scss/bootstrap'; - -.navbar-laravel { - background-color: #fff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); -} diff --git a/src/Illuminate/Foundation/Console/Presets/none-stubs/app.js b/src/Illuminate/Foundation/Console/Presets/none-stubs/app.js deleted file mode 100644 index 31d6f636c30786e24c0ec89a8b3ff35225c23f4f..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/none-stubs/app.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * First, we will load all of this project's Javascript utilities and other - * dependencies. Then, we will be ready to develop a robust and powerful - * application frontend using useful Laravel and JavaScript libraries. - */ - -require('./bootstrap'); diff --git a/src/Illuminate/Foundation/Console/Presets/none-stubs/bootstrap.js b/src/Illuminate/Foundation/Console/Presets/none-stubs/bootstrap.js deleted file mode 100644 index 0c8a1b5265176f0c5a7c978d707eec5fbf88f6c2..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/none-stubs/bootstrap.js +++ /dev/null @@ -1,42 +0,0 @@ -window._ = require('lodash'); - -/** - * We'll load the axios HTTP library which allows us to easily issue requests - * to our Laravel back-end. This library automatically handles sending the - * CSRF token as a header based on the value of the "XSRF" token cookie. - */ - -window.axios = require('axios'); - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; - -/** - * Next we will register the CSRF Token as a common header with Axios so that - * all outgoing HTTP requests automatically have it attached. This is just - * a simple convenience so we don't have to attach every token manually. - */ - -let token = document.head.querySelector('meta[name="csrf-token"]'); - -if (token) { - window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content; -} else { - console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token'); -} - -/** - * Echo exposes an expressive API for subscribing to channels and listening - * for events that are broadcast by Laravel. Echo and event broadcasting - * allows your team to easily build robust real-time web applications. - */ - -// import Echo from 'laravel-echo' - -// window.Pusher = require('pusher-js'); - -// window.Echo = new Echo({ -// broadcaster: 'pusher', -// key: process.env.MIX_PUSHER_APP_KEY, -// cluster: process.env.MIX_PUSHER_APP_CLUSTER, -// encrypted: true -// }); diff --git a/src/Illuminate/Foundation/Console/Presets/none-stubs/webpack.mix.js b/src/Illuminate/Foundation/Console/Presets/none-stubs/webpack.mix.js deleted file mode 100644 index 19a48fa1314868e3836bcd59721e3b6e50386dac..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/none-stubs/webpack.mix.js +++ /dev/null @@ -1,15 +0,0 @@ -const mix = require('laravel-mix'); - -/* - |-------------------------------------------------------------------------- - | Mix Asset Management - |-------------------------------------------------------------------------- - | - | Mix provides a clean, fluent API for defining some Webpack build steps - | for your Laravel application. By default, we are compiling the Sass - | file for the application as well as bundling up all the JS files. - | - */ - -mix.js('resources/js/app.js', 'public/js') - .sass('resources/sass/app.scss', 'public/css'); diff --git a/src/Illuminate/Foundation/Console/Presets/react-stubs/Example.js b/src/Illuminate/Foundation/Console/Presets/react-stubs/Example.js deleted file mode 100644 index eac7e85087284596bc6575d1a96951bb008894b3..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/react-stubs/Example.js +++ /dev/null @@ -1,24 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; - -export default class Example extends Component { - render() { - return ( - <div className="container"> - <div className="row justify-content-center"> - <div className="col-md-8"> - <div className="card"> - <div className="card-header">Example Component</div> - - <div className="card-body">I'm an example component!</div> - </div> - </div> - </div> - </div> - ); - } -} - -if (document.getElementById('example')) { - ReactDOM.render(<Example />, document.getElementById('example')); -} diff --git a/src/Illuminate/Foundation/Console/Presets/react-stubs/app.js b/src/Illuminate/Foundation/Console/Presets/react-stubs/app.js deleted file mode 100644 index a5f91ab386dab2e6593c633cf94ddd71f71c591e..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/react-stubs/app.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * First we will load all of this project's JavaScript dependencies which - * includes React and other helpers. It's a great starting point while - * building robust, powerful web applications using React + Laravel. - */ - -require('./bootstrap'); - -/** - * Next, we will create a fresh React component instance and attach it to - * the page. Then, you may begin adding components to this application - * or customize the JavaScript scaffolding to fit your unique needs. - */ - -require('./components/Example'); diff --git a/src/Illuminate/Foundation/Console/Presets/react-stubs/webpack.mix.js b/src/Illuminate/Foundation/Console/Presets/react-stubs/webpack.mix.js deleted file mode 100644 index cc075aa9c20b06f2f515930384578677c43a5f88..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/react-stubs/webpack.mix.js +++ /dev/null @@ -1,15 +0,0 @@ -const mix = require('laravel-mix'); - -/* - |-------------------------------------------------------------------------- - | Mix Asset Management - |-------------------------------------------------------------------------- - | - | Mix provides a clean, fluent API for defining some Webpack build steps - | for your Laravel application. By default, we are compiling the Sass - | file for the application as well as bundling up all the JS files. - | - */ - -mix.react('resources/js/app.js', 'public/js') - .sass('resources/sass/app.scss', 'public/css'); diff --git a/src/Illuminate/Foundation/Console/Presets/vue-stubs/ExampleComponent.vue b/src/Illuminate/Foundation/Console/Presets/vue-stubs/ExampleComponent.vue deleted file mode 100644 index 3fb9f9aa7c0e893972bf9cefc86c6c4a32941182..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/vue-stubs/ExampleComponent.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> - <div class="container"> - <div class="row justify-content-center"> - <div class="col-md-8"> - <div class="card"> - <div class="card-header">Example Component</div> - - <div class="card-body"> - I'm an example component. - </div> - </div> - </div> - </div> - </div> -</template> - -<script> - export default { - mounted() { - console.log('Component mounted.') - } - } -</script> diff --git a/src/Illuminate/Foundation/Console/Presets/vue-stubs/app.js b/src/Illuminate/Foundation/Console/Presets/vue-stubs/app.js deleted file mode 100644 index aa19e31aefbfc9439d0502dcf3718ffef8f02e37..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/vue-stubs/app.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * First we will load all of this project's JavaScript dependencies which - * includes Vue and other libraries. It is a great starting point when - * building robust, powerful web applications using Vue and Laravel. - */ - -require('./bootstrap'); - -window.Vue = require('vue'); - -/** - * The following block of code may be used to automatically register your - * Vue components. It will recursively scan this directory for the Vue - * components and automatically register them with their "basename". - * - * Eg. ./components/ExampleComponent.vue -> <example-component></example-component> - */ - -// const files = require.context('./', true, /\.vue$/i) -// files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default)) - -Vue.component('example-component', require('./components/ExampleComponent.vue').default); - -/** - * Next, we will create a fresh Vue application instance and attach it to - * the page. Then, you may begin adding components to this application - * or customize the JavaScript scaffolding to fit your unique needs. - */ - -const app = new Vue({ - el: '#app', -}); diff --git a/src/Illuminate/Foundation/Console/Presets/vue-stubs/webpack.mix.js b/src/Illuminate/Foundation/Console/Presets/vue-stubs/webpack.mix.js deleted file mode 100644 index 19a48fa1314868e3836bcd59721e3b6e50386dac..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Console/Presets/vue-stubs/webpack.mix.js +++ /dev/null @@ -1,15 +0,0 @@ -const mix = require('laravel-mix'); - -/* - |-------------------------------------------------------------------------- - | Mix Asset Management - |-------------------------------------------------------------------------- - | - | Mix provides a clean, fluent API for defining some Webpack build steps - | for your Laravel application. By default, we are compiling the Sass - | file for the application as well as bundling up all the JS files. - | - */ - -mix.js('resources/js/app.js', 'public/js') - .sass('resources/sass/app.scss', 'public/css'); diff --git a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php index fa887edb625104306cad2d229555d613a1ab0e88..ffe6499811d988634738f3d6c1084a47b5124ecb 100644 --- a/src/Illuminate/Foundation/Console/ProviderMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ProviderMakeCommand.php @@ -34,7 +34,20 @@ class ProviderMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/provider.stub'; + return $this->resolveStubPath('/stubs/provider.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/RequestMakeCommand.php b/src/Illuminate/Foundation/Console/RequestMakeCommand.php index 95b7a87b9dc9300567327465dea132d18e22fdcf..4605c818527ad4b62802f132f25509f6d031c2ba 100644 --- a/src/Illuminate/Foundation/Console/RequestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RequestMakeCommand.php @@ -34,7 +34,20 @@ class RequestMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/request.stub'; + return $this->resolveStubPath('/stubs/request.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php index 1fd28d26e3a10c765d47d3afaf1107cc40f71083..abaf6f04a35f26044309838fcb3c5f95e4ac185e 100644 --- a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php @@ -51,8 +51,8 @@ class ResourceMakeCommand extends GeneratorCommand protected function getStub() { return $this->collection() - ? __DIR__.'/stubs/resource-collection.stub' - : __DIR__.'/stubs/resource.stub'; + ? $this->resolveStubPath('/stubs/resource-collection.stub') + : $this->resolveStubPath('/stubs/resource.stub'); } /** @@ -66,6 +66,19 @@ class ResourceMakeCommand extends GeneratorCommand Str::endsWith($this->argument('name'), 'Collection'); } + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + /** * Get the default namespace for the class. * diff --git a/src/Illuminate/Foundation/Console/RouteCacheCommand.php b/src/Illuminate/Foundation/Console/RouteCacheCommand.php index f61e26ba47800b26a824d6ea465f5e982f4417a6..05e52670ffdd6b66f3cbda144c02689804cb5d91 100644 --- a/src/Illuminate/Foundation/Console/RouteCacheCommand.php +++ b/src/Illuminate/Foundation/Console/RouteCacheCommand.php @@ -104,6 +104,6 @@ class RouteCacheCommand extends Command { $stub = $this->files->get(__DIR__.'/stubs/routes.stub'); - return str_replace('{{routes}}', base64_encode(serialize($routes)), $stub); + return str_replace('{{routes}}', var_export($routes->compile(), true), $stub); } } diff --git a/src/Illuminate/Foundation/Console/RouteListCommand.php b/src/Illuminate/Foundation/Console/RouteListCommand.php index 14fd928687edd7eed600967bdd8fee183fb2e9da..956a6519ed611fa0065a12d4c1c72125da7dd9d9 100644 --- a/src/Illuminate/Foundation/Console/RouteListCommand.php +++ b/src/Illuminate/Foundation/Console/RouteListCommand.php @@ -36,14 +36,14 @@ class RouteListCommand extends Command /** * The table headers for the command. * - * @var array + * @var string[] */ protected $headers = ['Domain', 'Method', 'URI', 'Name', 'Action', 'Middleware']; /** * The columns to display when using the "compact" flag. * - * @var array + * @var string[] */ protected $compactColumns = ['method', 'uri', 'action']; @@ -67,6 +67,8 @@ class RouteListCommand extends Command */ public function handle() { + $this->router->flushMiddlewareGroups(); + if (empty($this->router->getRoutes())) { return $this->error("Your application doesn't have any routes."); } @@ -89,7 +91,7 @@ class RouteListCommand extends Command return $this->getRouteInformation($route); })->filter()->all(); - if ($sort = $this->option('sort')) { + if (($sort = $this->option('sort')) !== 'precedence') { $routes = $this->sortRoutes($sort, $routes); } @@ -111,8 +113,8 @@ class RouteListCommand extends Command return $this->filterRoute([ 'domain' => $route->domain(), 'method' => implode('|', $route->methods()), - 'uri' => $route->uri(), - 'name' => $route->getName(), + 'uri' => $route->uri(), + 'name' => $route->getName(), 'action' => ltrim($route->getActionName(), '\\'), 'middleware' => $this->getMiddleware($route), ]); @@ -154,7 +156,7 @@ class RouteListCommand extends Command protected function displayRoutes(array $routes) { if ($this->option('json')) { - $this->line(json_encode(array_values($routes))); + $this->line($this->asJson($routes)); return; } @@ -163,16 +165,16 @@ class RouteListCommand extends Command } /** - * Get before filters. + * Get the middleware for the route. * * @param \Illuminate\Routing\Route $route * @return string */ protected function getMiddleware($route) { - return collect($route->gatherMiddleware())->map(function ($middleware) { + return collect($this->router->gatherRouteMiddleware($route))->map(function ($middleware) { return $middleware instanceof Closure ? 'Closure' : $middleware; - })->implode(','); + })->implode("\n"); } /** @@ -189,6 +191,14 @@ class RouteListCommand extends Command return; } + if ($this->option('except-path')) { + foreach (explode(',', $this->option('except-path')) as $path) { + if (Str::contains($route['uri'], $path)) { + return; + } + } + } + return $route; } @@ -240,7 +250,25 @@ class RouteListCommand extends Command } } - return $results; + return array_map('strtolower', $results); + } + + /** + * Convert the given routes to JSON. + * + * @param array $routes + * @return string + */ + protected function asJson(array $routes) + { + return collect($routes) + ->map(function ($route) { + $route['middleware'] = empty($route['middleware']) ? [] : explode("\n", $route['middleware']); + + return $route; + }) + ->values() + ->toJson(); } /** @@ -256,9 +284,10 @@ class RouteListCommand extends Command ['json', null, InputOption::VALUE_NONE, 'Output the route list as JSON'], ['method', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by method'], ['name', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by name'], - ['path', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by path'], + ['path', null, InputOption::VALUE_OPTIONAL, 'Only show routes matching the given path pattern'], + ['except-path', null, InputOption::VALUE_OPTIONAL, 'Do not display the routes matching the given path pattern'], ['reverse', 'r', InputOption::VALUE_NONE, 'Reverse the ordering of the routes'], - ['sort', null, InputOption::VALUE_OPTIONAL, 'The column (domain, method, uri, name, action, middleware) to sort by', 'uri'], + ['sort', null, InputOption::VALUE_OPTIONAL, 'The column (precedence, domain, method, uri, name, action, middleware) to sort by', 'uri'], ]; } } diff --git a/src/Illuminate/Foundation/Console/RuleMakeCommand.php b/src/Illuminate/Foundation/Console/RuleMakeCommand.php index 2b6995358e6fef9c67be03bb89575db76f08ced4..b6f2a1d3b589ea09871fe864b53ad9081d008906 100644 --- a/src/Illuminate/Foundation/Console/RuleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/RuleMakeCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\GeneratorCommand; +use Symfony\Component\Console\Input\InputOption; class RuleMakeCommand extends GeneratorCommand { @@ -27,6 +28,23 @@ class RuleMakeCommand extends GeneratorCommand */ protected $type = 'Rule'; + /** + * Build the class with the given name. + * + * @param string $name + * @return string + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + protected function buildClass($name) + { + return str_replace( + '{{ ruleType }}', + $this->option('implicit') ? 'ImplicitRule' : 'Rule', + parent::buildClass($name) + ); + } + /** * Get the stub file for the generator. * @@ -34,7 +52,11 @@ class RuleMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/rule.stub'; + $relativePath = '/stubs/rule.stub'; + + return file_exists($customPath = $this->laravel->basePath(trim($relativePath, '/'))) + ? $customPath + : __DIR__.$relativePath; } /** @@ -47,4 +69,16 @@ class RuleMakeCommand extends GeneratorCommand { return $rootNamespace.'\Rules'; } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['implicit', 'i', InputOption::VALUE_NONE, 'Generate an implicit rule.'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ServeCommand.php b/src/Illuminate/Foundation/Console/ServeCommand.php index b931ea227e71741175b820af59f61b65db5127fb..ddb07e824eca274b2b9286d1bdeaad64d6410e07 100644 --- a/src/Illuminate/Foundation/Console/ServeCommand.php +++ b/src/Illuminate/Foundation/Console/ServeCommand.php @@ -4,9 +4,9 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; use Illuminate\Support\Env; -use Illuminate\Support\ProcessUtils; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; class ServeCommand extends Command { @@ -42,9 +42,41 @@ class ServeCommand extends Command { chdir(public_path()); - $this->line("<info>Laravel development server started:</info> http://{$this->host()}:{$this->port()}"); + $this->line("<info>Starting Laravel development server:</info> http://{$this->host()}:{$this->port()}"); - passthru($this->serverCommand(), $status); + $environmentFile = $this->option('env') + ? base_path('.env').'.'.$this->option('env') + : base_path('.env'); + + $hasEnvironment = file_exists($environmentFile); + + $environmentLastModified = $hasEnvironment + ? filemtime($environmentFile) + : now()->addDays(30)->getTimestamp(); + + $process = $this->startProcess($hasEnvironment); + + while ($process->isRunning()) { + if ($hasEnvironment) { + clearstatcache(false, $environmentFile); + } + + if (! $this->option('no-reload') && + $hasEnvironment && + filemtime($environmentFile) > $environmentLastModified) { + $environmentLastModified = filemtime($environmentFile); + + $this->comment('Environment modified. Restarting server...'); + + $process->stop(5); + + $process = $this->startProcess($hasEnvironment); + } + + usleep(500 * 1000); + } + + $status = $process->getExitCode(); if ($status && $this->canTryAnotherPort()) { $this->portOffset += 1; @@ -55,19 +87,51 @@ class ServeCommand extends Command return $status; } + /** + * Start a new server process. + * + * @param bool $hasEnvironment + * @return \Symfony\Component\Process\Process + */ + protected function startProcess($hasEnvironment) + { + $process = new Process($this->serverCommand(), null, collect($_ENV)->mapWithKeys(function ($value, $key) use ($hasEnvironment) { + if ($this->option('no-reload') || ! $hasEnvironment) { + return [$key => $value]; + } + + return in_array($key, [ + 'APP_ENV', + 'LARAVEL_SAIL', + 'PHP_CLI_SERVER_WORKERS', + 'PHP_IDE_CONFIG', + 'SYSTEMROOT', + 'XDEBUG_CONFIG', + 'XDEBUG_MODE', + 'XDEBUG_SESSION', + ]) ? [$key => $value] : [$key => false]; + })->all()); + + $process->start(function ($type, $buffer) { + $this->output->write($buffer); + }); + + return $process; + } + /** * Get the full server command. * - * @return string + * @return array */ protected function serverCommand() { - return sprintf('%s -S %s:%s %s', - ProcessUtils::escapeArgument((new PhpExecutableFinder)->find(false)), - $this->host(), - $this->port(), - ProcessUtils::escapeArgument(base_path('server.php')) - ); + return [ + (new PhpExecutableFinder)->find(false), + '-S', + $this->host().':'.$this->port(), + base_path('server.php'), + ]; } /** @@ -77,7 +141,9 @@ class ServeCommand extends Command */ protected function host() { - return $this->input->getOption('host'); + [$host, ] = $this->getHostAndPort(); + + return $host; } /** @@ -87,13 +153,34 @@ class ServeCommand extends Command */ protected function port() { - $port = $this->input->getOption('port') ?: 8000; + $port = $this->input->getOption('port'); + + if (is_null($port)) { + [, $port] = $this->getHostAndPort(); + } + + $port = $port ?: 8000; return $port + $this->portOffset; } /** - * Check if command has reached its max amount of port tries. + * Get the host and port from the host option string. + * + * @return array + */ + protected function getHostAndPort() + { + $hostParts = explode(':', $this->input->getOption('host')); + + return [ + $hostParts[0], + $hostParts[1] ?? null, + ]; + } + + /** + * Check if the command has reached its max amount of port tries. * * @return bool */ @@ -112,10 +199,9 @@ class ServeCommand extends Command { return [ ['host', null, InputOption::VALUE_OPTIONAL, 'The host address to serve the application on', '127.0.0.1'], - ['port', null, InputOption::VALUE_OPTIONAL, 'The port to serve the application on', Env::get('SERVER_PORT')], - ['tries', null, InputOption::VALUE_OPTIONAL, 'The max number of ports to attempt to serve from', 10], + ['no-reload', null, InputOption::VALUE_NONE, 'Do not reload the development server on .env file changes'], ]; } } diff --git a/src/Illuminate/Foundation/Console/StorageLinkCommand.php b/src/Illuminate/Foundation/Console/StorageLinkCommand.php index 11e5c148c1cef19eb041493596ec807a64fa4ca3..7926d80775e9666ef975481125d79cbacbb7b618 100644 --- a/src/Illuminate/Foundation/Console/StorageLinkCommand.php +++ b/src/Illuminate/Foundation/Console/StorageLinkCommand.php @@ -11,14 +11,16 @@ class StorageLinkCommand extends Command * * @var string */ - protected $signature = 'storage:link'; + protected $signature = 'storage:link + {--relative : Create the symbolic link using relative paths} + {--force : Recreate existing symbolic links}'; /** * The console command description. * * @var string */ - protected $description = 'Create a symbolic link from "public/storage" to "storage/app/public"'; + protected $description = 'Create the symbolic links configured for the application'; /** * Execute the console command. @@ -27,14 +29,50 @@ class StorageLinkCommand extends Command */ public function handle() { - if (file_exists(public_path('storage'))) { - return $this->error('The "public/storage" directory already exists.'); + $relative = $this->option('relative'); + + foreach ($this->links() as $link => $target) { + if (file_exists($link) && ! $this->isRemovableSymlink($link, $this->option('force'))) { + $this->error("The [$link] link already exists."); + continue; + } + + if (is_link($link)) { + $this->laravel->make('files')->delete($link); + } + + if ($relative) { + $this->laravel->make('files')->relativeLink($target, $link); + } else { + $this->laravel->make('files')->link($target, $link); + } + + $this->info("The [$link] link has been connected to [$target]."); } - $this->laravel->make('files')->link( - storage_path('app/public'), public_path('storage') - ); + $this->info('The links have been created.'); + } - $this->info('The [public/storage] directory has been linked.'); + /** + * Get the symbolic links that are configured for the application. + * + * @return array + */ + protected function links() + { + return $this->laravel['config']['filesystems.links'] ?? + [public_path('storage') => storage_path('app/public')]; + } + + /** + * Determine if the provided path is a symlink that can be removed. + * + * @param string $link + * @param bool $force + * @return bool + */ + protected function isRemovableSymlink(string $link, bool $force): bool + { + return is_link($link) && $force; } } diff --git a/src/Illuminate/Foundation/Console/StubPublishCommand.php b/src/Illuminate/Foundation/Console/StubPublishCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..4594f07cd197c9a0203f6294078f1bf3560ef497 --- /dev/null +++ b/src/Illuminate/Foundation/Console/StubPublishCommand.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Foundation\Console; + +use Illuminate\Console\Command; +use Illuminate\Filesystem\Filesystem; + +class StubPublishCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'stub:publish {--force : Overwrite any existing files}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Publish all stubs that are available for customization'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + if (! is_dir($stubsPath = $this->laravel->basePath('stubs'))) { + (new Filesystem)->makeDirectory($stubsPath); + } + + $files = [ + __DIR__.'/stubs/cast.stub' => $stubsPath.'/cast.stub', + __DIR__.'/stubs/console.stub' => $stubsPath.'/console.stub', + __DIR__.'/stubs/event.stub' => $stubsPath.'/event.stub', + __DIR__.'/stubs/job.queued.stub' => $stubsPath.'/job.queued.stub', + __DIR__.'/stubs/job.stub' => $stubsPath.'/job.stub', + __DIR__.'/stubs/mail.stub' => $stubsPath.'/mail.stub', + __DIR__.'/stubs/markdown-mail.stub' => $stubsPath.'/markdown-mail.stub', + __DIR__.'/stubs/markdown-notification.stub' => $stubsPath.'/markdown-notification.stub', + __DIR__.'/stubs/model.pivot.stub' => $stubsPath.'/model.pivot.stub', + __DIR__.'/stubs/model.stub' => $stubsPath.'/model.stub', + __DIR__.'/stubs/notification.stub' => $stubsPath.'/notification.stub', + __DIR__.'/stubs/observer.plain.stub' => $stubsPath.'/observer.plain.stub', + __DIR__.'/stubs/observer.stub' => $stubsPath.'/observer.stub', + __DIR__.'/stubs/policy.plain.stub' => $stubsPath.'/policy.plain.stub', + __DIR__.'/stubs/policy.stub' => $stubsPath.'/policy.stub', + __DIR__.'/stubs/provider.stub' => $stubsPath.'/provider.stub', + __DIR__.'/stubs/request.stub' => $stubsPath.'/request.stub', + __DIR__.'/stubs/resource-collection.stub' => $stubsPath.'/resource-collection.stub', + __DIR__.'/stubs/resource.stub' => $stubsPath.'/resource.stub', + __DIR__.'/stubs/rule.stub' => $stubsPath.'/rule.stub', + __DIR__.'/stubs/test.stub' => $stubsPath.'/test.stub', + __DIR__.'/stubs/test.unit.stub' => $stubsPath.'/test.unit.stub', + __DIR__.'/stubs/view-component.stub' => $stubsPath.'/view-component.stub', + realpath(__DIR__.'/../../Database/Console/Factories/stubs/factory.stub') => $stubsPath.'/factory.stub', + realpath(__DIR__.'/../../Database/Console/Seeds/stubs/seeder.stub') => $stubsPath.'/seeder.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.create.stub') => $stubsPath.'/migration.create.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.stub') => $stubsPath.'/migration.stub', + realpath(__DIR__.'/../../Database/Migrations/stubs/migration.update.stub') => $stubsPath.'/migration.update.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.api.stub') => $stubsPath.'/controller.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.invokable.stub') => $stubsPath.'/controller.invokable.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.api.stub') => $stubsPath.'/controller.model.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.model.stub') => $stubsPath.'/controller.model.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.api.stub') => $stubsPath.'/controller.nested.api.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.nested.stub') => $stubsPath.'/controller.nested.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.plain.stub') => $stubsPath.'/controller.plain.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/controller.stub') => $stubsPath.'/controller.stub', + realpath(__DIR__.'/../../Routing/Console/stubs/middleware.stub') => $stubsPath.'/middleware.stub', + ]; + + foreach ($files as $from => $to) { + if (! file_exists($to) || $this->option('force')) { + file_put_contents($to, file_get_contents($from)); + } + } + + $this->info('Stubs published successfully.'); + } +} diff --git a/src/Illuminate/Foundation/Console/TestMakeCommand.php b/src/Illuminate/Foundation/Console/TestMakeCommand.php index 173932465b7b1d72804b34410c2eb528d8907599..eced47b918afbbabc21d754980c2434584a3cb69 100644 --- a/src/Illuminate/Foundation/Console/TestMakeCommand.php +++ b/src/Illuminate/Foundation/Console/TestMakeCommand.php @@ -4,6 +4,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputOption; class TestMakeCommand extends GeneratorCommand { @@ -12,7 +13,7 @@ class TestMakeCommand extends GeneratorCommand * * @var string */ - protected $signature = 'make:test {name : The name of the class} {--unit : Create a unit test}'; + protected $name = 'make:test'; /** * The console command description. @@ -35,11 +36,24 @@ class TestMakeCommand extends GeneratorCommand */ protected function getStub() { - if ($this->option('unit')) { - return __DIR__.'/stubs/unit-test.stub'; - } + $suffix = $this->option('unit') ? '.unit.stub' : '.stub'; - return __DIR__.'/stubs/test.stub'; + return $this->option('pest') + ? $this->resolveStubPath('/stubs/pest'.$suffix) + : $this->resolveStubPath('/stubs/test'.$suffix); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -79,4 +93,17 @@ class TestMakeCommand extends GeneratorCommand { return 'Tests'; } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['unit', 'u', InputOption::VALUE_NONE, 'Create a unit test.'], + ['pest', 'p', InputOption::VALUE_NONE, 'Create a Pest test.'], + ]; + } } diff --git a/src/Illuminate/Foundation/Console/UpCommand.php b/src/Illuminate/Foundation/Console/UpCommand.php index 9f659920833ef761244c878f722432f82eab9907..b651247dbab29049c465c9fe43c5b7d632ae0d5a 100644 --- a/src/Illuminate/Foundation/Console/UpCommand.php +++ b/src/Illuminate/Foundation/Console/UpCommand.php @@ -4,6 +4,7 @@ namespace Illuminate\Foundation\Console; use Exception; use Illuminate\Console\Command; +use Illuminate\Foundation\Events\MaintenanceModeDisabled; class UpCommand extends Command { @@ -29,14 +30,20 @@ class UpCommand extends Command public function handle() { try { - if (! file_exists(storage_path('framework/down'))) { + if (! is_file(storage_path('framework/down'))) { $this->comment('Application is already up.'); - return true; + return 0; } unlink(storage_path('framework/down')); + if (is_file(storage_path('framework/maintenance.php'))) { + unlink(storage_path('framework/maintenance.php')); + } + + $this->laravel->get('events')->dispatch(MaintenanceModeDisabled::class); + $this->info('Application is now live.'); } catch (Exception $e) { $this->error('Failed to disable maintenance mode.'); diff --git a/src/Illuminate/Foundation/Console/VendorPublishCommand.php b/src/Illuminate/Foundation/Console/VendorPublishCommand.php index 17a459e72834b54382eed40760d99ae0919f152c..db28b9e6b2ecc2675dff738e5624cb8579e287c5 100644 --- a/src/Illuminate/Foundation/Console/VendorPublishCommand.php +++ b/src/Illuminate/Foundation/Console/VendorPublishCommand.php @@ -4,6 +4,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use Illuminate\Foundation\Events\VendorTagPublished; use Illuminate\Support\Arr; use Illuminate\Support\ServiceProvider; use League\Flysystem\Adapter\Local as LocalAdapter; @@ -159,14 +160,18 @@ class VendorPublishCommand extends Command { $published = false; - foreach ($this->pathsToPublish($tag) as $from => $to) { + $pathsToPublish = $this->pathsToPublish($tag); + + foreach ($pathsToPublish as $from => $to) { $this->publishItem($from, $to); $published = true; } if ($published === false) { - $this->error('Unable to locate publishable resources.'); + $this->comment('No publishable resources for tag ['.$tag.'].'); + } else { + $this->laravel['events']->dispatch(new VendorTagPublished($tag, $pathsToPublish)); } } diff --git a/src/Illuminate/Foundation/Console/stubs/cast.stub b/src/Illuminate/Foundation/Console/stubs/cast.stub new file mode 100644 index 0000000000000000000000000000000000000000..25d35b68ea7f76a6e3fc5d64313d6d82804d9c36 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/cast.stub @@ -0,0 +1,36 @@ +<?php + +namespace {{ namespace }}; + +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; + +class {{ class }} implements CastsAttributes +{ + /** + * Cast the given value. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function get($model, string $key, $value, array $attributes) + { + return $value; + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function set($model, string $key, $value, array $attributes) + { + return $value; + } +} diff --git a/src/Illuminate/Foundation/Console/stubs/channel.stub b/src/Illuminate/Foundation/Console/stubs/channel.stub index bf261ccf93d29b2eeadcc1d01418e79c9eae4713..1b51698085dbf2a557d05f6155d19f1afc719dd6 100644 --- a/src/Illuminate/Foundation/Console/stubs/channel.stub +++ b/src/Illuminate/Foundation/Console/stubs/channel.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use NamespacedDummyUserModel; +use {{ namespacedUserModel }}; -class DummyClass +class {{ class }} { /** * Create a new channel instance. @@ -19,10 +19,10 @@ class DummyClass /** * Authenticate the user's access to the channel. * - * @param \NamespacedDummyUserModel $user + * @param \{{ namespacedUserModel }} $user * @return array|bool */ - public function join(DummyUser $user) + public function join({{ userModel }} $user) { // } diff --git a/src/Illuminate/Foundation/Console/stubs/console.stub b/src/Illuminate/Foundation/Console/stubs/console.stub index 9b770d9c289fe5957072334c0c99a1daa6451ad2..0f751a3c75fd689750b3418a974fc6b2a51355ac 100644 --- a/src/Illuminate/Foundation/Console/stubs/console.stub +++ b/src/Illuminate/Foundation/Console/stubs/console.stub @@ -1,17 +1,17 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Console\Command; -class DummyClass extends Command +class {{ class }} extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'dummy:command'; + protected $signature = '{{ command }}'; /** * The console command description. @@ -33,10 +33,10 @@ class DummyClass extends Command /** * Execute the console command. * - * @return mixed + * @return int */ public function handle() { - // + return 0; } } diff --git a/src/Illuminate/Foundation/Console/stubs/event.stub b/src/Illuminate/Foundation/Console/stubs/event.stub index c979d9b8ffcdd711537c8e4c5f0968442fcd9057..47f8c913ad2de096d4f60c3dc929694924f76478 100644 --- a/src/Illuminate/Foundation/Console/stubs/event.stub +++ b/src/Illuminate/Foundation/Console/stubs/event.stub @@ -1,6 +1,6 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; @@ -10,7 +10,7 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class DummyClass +class {{ class }} { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/src/Illuminate/Foundation/Console/stubs/exception-render-report.stub b/src/Illuminate/Foundation/Console/stubs/exception-render-report.stub index 712a40789e765c0a1808dfa0230725b0582804cc..b72fa575f94d1eed602ac03e9b00076f67410d84 100644 --- a/src/Illuminate/Foundation/Console/stubs/exception-render-report.stub +++ b/src/Illuminate/Foundation/Console/stubs/exception-render-report.stub @@ -1,15 +1,15 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Exception; -class DummyClass extends Exception +class {{ class }} extends Exception { /** * Report the exception. * - * @return void + * @return bool|null */ public function report() { diff --git a/src/Illuminate/Foundation/Console/stubs/exception-render.stub b/src/Illuminate/Foundation/Console/stubs/exception-render.stub index 0fde3d9dceefc8d32fd98a72bed773789c652ed6..b397f2124e5627578ca720c42a865470aa4ad680 100644 --- a/src/Illuminate/Foundation/Console/stubs/exception-render.stub +++ b/src/Illuminate/Foundation/Console/stubs/exception-render.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Exception; -class DummyClass extends Exception +class {{ class }} extends Exception { /** * Render the exception as an HTTP response. diff --git a/src/Illuminate/Foundation/Console/stubs/exception-report.stub b/src/Illuminate/Foundation/Console/stubs/exception-report.stub index 8db5c4f3c2b20d6ab649ff2a4751e8e2cdaa3de0..250db609f0cb04e7e9032e4a3ac0ed7cc10ffacb 100644 --- a/src/Illuminate/Foundation/Console/stubs/exception-report.stub +++ b/src/Illuminate/Foundation/Console/stubs/exception-report.stub @@ -1,15 +1,15 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Exception; -class DummyClass extends Exception +class {{ class }} extends Exception { /** * Report the exception. * - * @return void + * @return bool|null */ public function report() { diff --git a/src/Illuminate/Foundation/Console/stubs/exception.stub b/src/Illuminate/Foundation/Console/stubs/exception.stub index fc3587d8285d45e697715921d486b4cf2d90cf39..62bb20429eb47c218d760dd877add716cd626ee3 100644 --- a/src/Illuminate/Foundation/Console/stubs/exception.stub +++ b/src/Illuminate/Foundation/Console/stubs/exception.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Exception; -class DummyClass extends Exception +class {{ class }} extends Exception { // } diff --git a/src/Illuminate/Foundation/Console/stubs/job-queued.stub b/src/Illuminate/Foundation/Console/stubs/job.queued.stub similarity index 82% rename from src/Illuminate/Foundation/Console/stubs/job-queued.stub rename to src/Illuminate/Foundation/Console/stubs/job.queued.stub index 262f9cf904cc0f04c0ab4c4dd36b1d128c07fd32..4b78746d8ac80de3147a1496e27b2b2e6c9eb7ef 100644 --- a/src/Illuminate/Foundation/Console/stubs/job-queued.stub +++ b/src/Illuminate/Foundation/Console/stubs/job.queued.stub @@ -1,14 +1,15 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DummyClass implements ShouldQueue +class {{ class }} implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/src/Illuminate/Foundation/Console/stubs/job.stub b/src/Illuminate/Foundation/Console/stubs/job.stub index 8f9db50b415559f80e9775e982323062ac428502..89dc9d727ceec940f721ec734f139f58be5f8939 100644 --- a/src/Illuminate/Foundation/Console/stubs/job.stub +++ b/src/Illuminate/Foundation/Console/stubs/job.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Foundation\Bus\Dispatchable; -class DummyClass +class {{ class }} { use Dispatchable; diff --git a/src/Illuminate/Foundation/Console/stubs/listener-duck.stub b/src/Illuminate/Foundation/Console/stubs/listener-duck.stub index 989a0a918f0844978d2e21691393cd870e020dcf..40b9a1eb616ba2c9f10ac84a03d94d86a2539fa4 100644 --- a/src/Illuminate/Foundation/Console/stubs/listener-duck.stub +++ b/src/Illuminate/Foundation/Console/stubs/listener-duck.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -class DummyClass +class {{ class }} { /** * Create the event listener. diff --git a/src/Illuminate/Foundation/Console/stubs/listener-queued-duck.stub b/src/Illuminate/Foundation/Console/stubs/listener-queued-duck.stub index 986a0e6471504b61715b5448f8c19a77bc5f8c2c..dd57a4561a56c4fadad0351671186d2553a27415 100644 --- a/src/Illuminate/Foundation/Console/stubs/listener-queued-duck.stub +++ b/src/Illuminate/Foundation/Console/stubs/listener-queued-duck.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -class DummyClass implements ShouldQueue +class {{ class }} implements ShouldQueue { use InteractsWithQueue; diff --git a/src/Illuminate/Foundation/Console/stubs/listener-queued.stub b/src/Illuminate/Foundation/Console/stubs/listener-queued.stub index 5d38a972db7b037fa44e7078db21cc094c7acce8..81cdacce597a79647f939c138b459c76b3bd5424 100644 --- a/src/Illuminate/Foundation/Console/stubs/listener-queued.stub +++ b/src/Illuminate/Foundation/Console/stubs/listener-queued.stub @@ -1,12 +1,12 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullEvent; +use {{ eventNamespace }}; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -class DummyClass implements ShouldQueue +class {{ class }} implements ShouldQueue { use InteractsWithQueue; @@ -23,10 +23,10 @@ class DummyClass implements ShouldQueue /** * Handle the event. * - * @param DummyEvent $event + * @param {{ eventNamespace }} $event * @return void */ - public function handle(DummyEvent $event) + public function handle({{ event }} $event) { // } diff --git a/src/Illuminate/Foundation/Console/stubs/listener.stub b/src/Illuminate/Foundation/Console/stubs/listener.stub index 1a7d528d1c5ce68f84979c5798dbd2230ef5dd8d..fe1b74473577a4fe9450b1d7c988c60815205a05 100644 --- a/src/Illuminate/Foundation/Console/stubs/listener.stub +++ b/src/Illuminate/Foundation/Console/stubs/listener.stub @@ -1,12 +1,12 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullEvent; +use {{ eventNamespace }}; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -class DummyClass +class {{ class }} { /** * Create the event listener. @@ -21,10 +21,10 @@ class DummyClass /** * Handle the event. * - * @param DummyEvent $event + * @param \{{ eventNamespace }} $event * @return void */ - public function handle(DummyEvent $event) + public function handle({{ event }} $event) { // } diff --git a/src/Illuminate/Foundation/Console/stubs/mail.stub b/src/Illuminate/Foundation/Console/stubs/mail.stub index 1fa0bcfcc882c21e028adaa3d3af73cd27123111..f432a815cec6d9dd783b1fdd23c7fa6cddc2f7ed 100644 --- a/src/Illuminate/Foundation/Console/stubs/mail.stub +++ b/src/Illuminate/Foundation/Console/stubs/mail.stub @@ -1,13 +1,13 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class DummyClass extends Mailable +class {{ class }} extends Mailable { use Queueable, SerializesModels; diff --git a/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub b/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub new file mode 100644 index 0000000000000000000000000000000000000000..a90b8cde8aec05b4f649b4f9c689124b42da9a0e --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub @@ -0,0 +1,78 @@ +<?php + +// Check if the application is in maintenance mode... +if (! file_exists($down = __DIR__.'/down')) { + return; +} + +// Decode the "down" file's JSON... +$data = json_decode(file_get_contents($down), true); + +// Allow framework to handle request if no prerendered template... +if (! isset($data['template'])) { + return; +} + +// Allow framework to handle request if request URI is in the exclude list... +if (isset($data['except'])) { + $uri = parse_url($_SERVER['REQUEST_URI'])['path']; + + $uri = rawurldecode($uri !== '/' ? trim($uri, '/') : $uri); + + foreach ((array) $data['except'] as $except) { + $except = $except !== '/' ? trim($except, '/') : $except; + + if ($except == $uri) { + return; + } + + $except = preg_quote($except, '#'); + + $except = str_replace('\*', '.*', $except); + + if (preg_match('#^'.$except.'\z#u', $uri) === 1) { + return; + } + } +} + +// Allow framework to handle maintenance mode bypass route... +if (isset($data['secret']) && $_SERVER['REQUEST_URI'] === '/'.$data['secret']) { + return; +} + +// Determine if maintenance mode bypass cookie is valid... +if (isset($_COOKIE['laravel_maintenance']) && isset($data['secret'])) { + $payload = json_decode(base64_decode($_COOKIE['laravel_maintenance']), true); + + if (is_array($payload) && + is_numeric($payload['expires_at'] ?? null) && + isset($payload['mac']) && + hash_equals(hash_hmac('sha256', $payload['expires_at'], $data['secret']), $payload['mac']) && + (int) $payload['expires_at'] >= time()) { + return; + } +} + +// Redirect to the proper path if necessary... +if (isset($data['redirect']) && $_SERVER['REQUEST_URI'] !== $data['redirect']) { + http_response_code(302); + header('Location: '.$data['redirect']); + + exit; +} + +// Output the prerendered template... +http_response_code($data['status'] ?? 503); + +if (isset($data['retry'])) { + header('Retry-After: '.$data['retry']); +} + +if (isset($data['refresh'])) { + header('Refresh: '.$data['refresh']); +} + +echo $data['template']; + +exit; diff --git a/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub b/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub index 7bf41616df5d1d7f7d3ff51542d672b73759ba31..e4c7cd4b93fa530692b99aef0802d6231747954a 100644 --- a/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub +++ b/src/Illuminate/Foundation/Console/stubs/markdown-mail.stub @@ -1,13 +1,13 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class DummyClass extends Mailable +class {{ class }} extends Mailable { use Queueable, SerializesModels; @@ -28,6 +28,6 @@ class DummyClass extends Mailable */ public function build() { - return $this->markdown('DummyView'); + return $this->markdown('{{ view }}'); } } diff --git a/src/Illuminate/Foundation/Console/stubs/markdown-notification.stub b/src/Illuminate/Foundation/Console/stubs/markdown-notification.stub index a2c060d63926b3a5c64f6b4418144ce2c9484ef0..5438f045511c7bfb1637279d9b104a7fc0e647a8 100644 --- a/src/Illuminate/Foundation/Console/stubs/markdown-notification.stub +++ b/src/Illuminate/Foundation/Console/stubs/markdown-notification.stub @@ -1,13 +1,13 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class DummyClass extends Notification +class {{ class }} extends Notification { use Queueable; @@ -40,7 +40,7 @@ class DummyClass extends Notification */ public function toMail($notifiable) { - return (new MailMessage)->markdown('DummyView'); + return (new MailMessage)->markdown('{{ view }}'); } /** diff --git a/src/Illuminate/Foundation/Console/stubs/pivot.model.stub b/src/Illuminate/Foundation/Console/stubs/model.pivot.stub similarity index 54% rename from src/Illuminate/Foundation/Console/stubs/pivot.model.stub rename to src/Illuminate/Foundation/Console/stubs/model.pivot.stub index 6a7352bce96cd920cd8887905054fb2b79e72147..35a674ad2d0b385c14fbfff0967fe4b5ac35ef35 100644 --- a/src/Illuminate/Foundation/Console/stubs/pivot.model.stub +++ b/src/Illuminate/Foundation/Console/stubs/model.pivot.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Database\Eloquent\Relations\Pivot; -class DummyClass extends Pivot +class {{ class }} extends Pivot { // } diff --git a/src/Illuminate/Foundation/Console/stubs/model.stub b/src/Illuminate/Foundation/Console/stubs/model.stub index f01a833371d0e3e7724a24b67d38d3f0b6b7488d..2956d090e7ca82d487f0b556583fcc792bbf8f88 100644 --- a/src/Illuminate/Foundation/Console/stubs/model.stub +++ b/src/Illuminate/Foundation/Console/stubs/model.stub @@ -1,10 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -class DummyClass extends Model +class {{ class }} extends Model { - // + use HasFactory; } diff --git a/src/Illuminate/Foundation/Console/stubs/notification.stub b/src/Illuminate/Foundation/Console/stubs/notification.stub index ae56ec0c28f9f5dd2bc193d2dbf3c44c5fa4dcd7..b170a463c78d514853b8cdfa8c529b767178548a 100644 --- a/src/Illuminate/Foundation/Console/stubs/notification.stub +++ b/src/Illuminate/Foundation/Console/stubs/notification.stub @@ -1,13 +1,13 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class DummyClass extends Notification +class {{ class }} extends Notification { use Queueable; diff --git a/src/Illuminate/Foundation/Console/stubs/observer.plain.stub b/src/Illuminate/Foundation/Console/stubs/observer.plain.stub index daae325c7ccbd454121f9cdb2ce66709f9e4cc74..e2506b7096d4e33be269bb1ed78669f003056348 100644 --- a/src/Illuminate/Foundation/Console/stubs/observer.plain.stub +++ b/src/Illuminate/Foundation/Console/stubs/observer.plain.stub @@ -1,8 +1,8 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -class DummyClass +class {{ class }} { // } diff --git a/src/Illuminate/Foundation/Console/stubs/observer.stub b/src/Illuminate/Foundation/Console/stubs/observer.stub index 84355ba5cf41e581244fd5740171b5555855ec4c..32ca095e06df7974a88fbfc6366824f22bbee021 100644 --- a/src/Illuminate/Foundation/Console/stubs/observer.stub +++ b/src/Illuminate/Foundation/Console/stubs/observer.stub @@ -1,62 +1,62 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use NamespacedDummyModel; +use {{ namespacedModel }}; -class DummyClass +class {{ class }} { /** - * Handle the DocDummyModel "created" event. + * Handle the {{ model }} "created" event. * - * @param \NamespacedDummyModel $dummyModel + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return void */ - public function created(DummyModel $dummyModel) + public function created({{ model }} ${{ modelVariable }}) { // } /** - * Handle the DocDummyModel "updated" event. + * Handle the {{ model }} "updated" event. * - * @param \NamespacedDummyModel $dummyModel + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return void */ - public function updated(DummyModel $dummyModel) + public function updated({{ model }} ${{ modelVariable }}) { // } /** - * Handle the DocDummyModel "deleted" event. + * Handle the {{ model }} "deleted" event. * - * @param \NamespacedDummyModel $dummyModel + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return void */ - public function deleted(DummyModel $dummyModel) + public function deleted({{ model }} ${{ modelVariable }}) { // } /** - * Handle the DocDummyModel "restored" event. + * Handle the {{ model }} "restored" event. * - * @param \NamespacedDummyModel $dummyModel + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return void */ - public function restored(DummyModel $dummyModel) + public function restored({{ model }} ${{ modelVariable }}) { // } /** - * Handle the DocDummyModel "force deleted" event. + * Handle the {{ model }} "force deleted" event. * - * @param \NamespacedDummyModel $dummyModel + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return void */ - public function forceDeleted(DummyModel $dummyModel) + public function forceDeleted({{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Foundation/Console/stubs/pest.stub b/src/Illuminate/Foundation/Console/stubs/pest.stub new file mode 100644 index 0000000000000000000000000000000000000000..b46239fd0e3d4b686c0bd71a48423e171a83ef42 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/pest.stub @@ -0,0 +1,7 @@ +<?php + +test('example', function () { + $response = $this->get('/'); + + $response->assertStatus(200); +}); diff --git a/src/Illuminate/Foundation/Console/stubs/pest.unit.stub b/src/Illuminate/Foundation/Console/stubs/pest.unit.stub new file mode 100644 index 0000000000000000000000000000000000000000..61cd84c327054d939ffff338d0b5a60a1eaa9db9 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/pest.unit.stub @@ -0,0 +1,5 @@ +<?php + +test('example', function () { + expect(true)->toBeTrue(); +}); diff --git a/src/Illuminate/Foundation/Console/stubs/policy.plain.stub b/src/Illuminate/Foundation/Console/stubs/policy.plain.stub index e0b9094d7610f859ffe8072fff3a4e0c891318d9..b014d85916996413c9c389c4a23647d125c22690 100644 --- a/src/Illuminate/Foundation/Console/stubs/policy.plain.stub +++ b/src/Illuminate/Foundation/Console/stubs/policy.plain.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Auth\Access\HandlesAuthorization; -use NamespacedDummyUserModel; +use {{ namespacedUserModel }}; -class DummyClass +class {{ class }} { use HandlesAuthorization; diff --git a/src/Illuminate/Foundation/Console/stubs/policy.stub b/src/Illuminate/Foundation/Console/stubs/policy.stub index e97a341efc79a5b3ff11a2cb62a7aa72d27e10bd..985babb5196264f405d6e9ccaa6fa9532b46b17a 100644 --- a/src/Illuminate/Foundation/Console/stubs/policy.stub +++ b/src/Illuminate/Foundation/Console/stubs/policy.stub @@ -1,93 +1,93 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Auth\Access\HandlesAuthorization; -use NamespacedDummyModel; -use NamespacedDummyUserModel; +use {{ namespacedModel }}; +use {{ namespacedUserModel }}; -class DummyClass +class {{ class }} { use HandlesAuthorization; /** - * Determine whether the user can view any DocDummyPluralModel. + * Determine whether the user can view any models. * - * @param \NamespacedDummyUserModel $user - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @return \Illuminate\Auth\Access\Response|bool */ - public function viewAny(DummyUser $user) + public function viewAny({{ user }} $user) { // } /** - * Determine whether the user can view the DocDummyModel. + * Determine whether the user can view the model. * - * @param \NamespacedDummyUserModel $user - * @param \NamespacedDummyModel $dummyModel - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @param \{{ namespacedModel }} ${{ modelVariable }} + * @return \Illuminate\Auth\Access\Response|bool */ - public function view(DummyUser $user, DummyModel $dummyModel) + public function view({{ user }} $user, {{ model }} ${{ modelVariable }}) { // } /** - * Determine whether the user can create DocDummyPluralModel. + * Determine whether the user can create models. * - * @param \NamespacedDummyUserModel $user - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @return \Illuminate\Auth\Access\Response|bool */ - public function create(DummyUser $user) + public function create({{ user }} $user) { // } /** - * Determine whether the user can update the DocDummyModel. + * Determine whether the user can update the model. * - * @param \NamespacedDummyUserModel $user - * @param \NamespacedDummyModel $dummyModel - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @param \{{ namespacedModel }} ${{ modelVariable }} + * @return \Illuminate\Auth\Access\Response|bool */ - public function update(DummyUser $user, DummyModel $dummyModel) + public function update({{ user }} $user, {{ model }} ${{ modelVariable }}) { // } /** - * Determine whether the user can delete the DocDummyModel. + * Determine whether the user can delete the model. * - * @param \NamespacedDummyUserModel $user - * @param \NamespacedDummyModel $dummyModel - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @param \{{ namespacedModel }} ${{ modelVariable }} + * @return \Illuminate\Auth\Access\Response|bool */ - public function delete(DummyUser $user, DummyModel $dummyModel) + public function delete({{ user }} $user, {{ model }} ${{ modelVariable }}) { // } /** - * Determine whether the user can restore the DocDummyModel. + * Determine whether the user can restore the model. * - * @param \NamespacedDummyUserModel $user - * @param \NamespacedDummyModel $dummyModel - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @param \{{ namespacedModel }} ${{ modelVariable }} + * @return \Illuminate\Auth\Access\Response|bool */ - public function restore(DummyUser $user, DummyModel $dummyModel) + public function restore({{ user }} $user, {{ model }} ${{ modelVariable }}) { // } /** - * Determine whether the user can permanently delete the DocDummyModel. + * Determine whether the user can permanently delete the model. * - * @param \NamespacedDummyUserModel $user - * @param \NamespacedDummyModel $dummyModel - * @return mixed + * @param \{{ namespacedUserModel }} $user + * @param \{{ namespacedModel }} ${{ modelVariable }} + * @return \Illuminate\Auth\Access\Response|bool */ - public function forceDelete(DummyUser $user, DummyModel $dummyModel) + public function forceDelete({{ user }} $user, {{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Foundation/Console/stubs/provider.stub b/src/Illuminate/Foundation/Console/stubs/provider.stub index fcd5d12416044627ffa3aaa82423d205206fa43f..6dedc5842ad40392e3eb8680ad0df4d0a4d33857 100644 --- a/src/Illuminate/Foundation/Console/stubs/provider.stub +++ b/src/Illuminate/Foundation/Console/stubs/provider.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Support\ServiceProvider; -class DummyClass extends ServiceProvider +class {{ class }} extends ServiceProvider { /** * Register services. diff --git a/src/Illuminate/Foundation/Console/stubs/request.stub b/src/Illuminate/Foundation/Console/stubs/request.stub index 6653f57a91a0df40671761c90282e15dfa1fae8a..9d644f0c93c70d306281f589184af2f8da7c50ce 100644 --- a/src/Illuminate/Foundation/Console/stubs/request.stub +++ b/src/Illuminate/Foundation/Console/stubs/request.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Foundation\Http\FormRequest; -class DummyClass extends FormRequest +class {{ class }} extends FormRequest { /** * Determine if the user is authorized to make this request. diff --git a/src/Illuminate/Foundation/Console/stubs/resource-collection.stub b/src/Illuminate/Foundation/Console/stubs/resource-collection.stub index 037432ca4916d13cc983cfbee5d3330ff3be444c..ddec9614a2208596778df79597cc7564bb5ea8a7 100644 --- a/src/Illuminate/Foundation/Console/stubs/resource-collection.stub +++ b/src/Illuminate/Foundation/Console/stubs/resource-collection.stub @@ -1,16 +1,16 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Http\Resources\Json\ResourceCollection; -class DummyClass extends ResourceCollection +class {{ class }} extends ResourceCollection { /** * Transform the resource collection into an array. * * @param \Illuminate\Http\Request $request - * @return array + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { diff --git a/src/Illuminate/Foundation/Console/stubs/resource.stub b/src/Illuminate/Foundation/Console/stubs/resource.stub index 99703653d3e348ec768246ed6b2c814a83ca6ed3..05bacc36501240a1589545d498726215175f596f 100644 --- a/src/Illuminate/Foundation/Console/stubs/resource.stub +++ b/src/Illuminate/Foundation/Console/stubs/resource.stub @@ -1,16 +1,16 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Http\Resources\Json\JsonResource; -class DummyClass extends JsonResource +class {{ class }} extends JsonResource { /** * Transform the resource into an array. * * @param \Illuminate\Http\Request $request - * @return array + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { diff --git a/src/Illuminate/Foundation/Console/stubs/routes.stub b/src/Illuminate/Foundation/Console/stubs/routes.stub index ed90572cbfb25eeefb7efac79b268a9da98686ee..fa6a67fb82ca2b6db97fb4cc065cc2de0a13570a 100644 --- a/src/Illuminate/Foundation/Console/stubs/routes.stub +++ b/src/Illuminate/Foundation/Console/stubs/routes.stub @@ -11,6 +11,6 @@ | */ -app('router')->setRoutes( - unserialize(base64_decode('{{routes}}')) +app('router')->setCompiledRoutes( + {{routes}} ); diff --git a/src/Illuminate/Foundation/Console/stubs/rule.stub b/src/Illuminate/Foundation/Console/stubs/rule.stub index 826af0d6c743e25e3428eba0995dd4d59e06215b..587ec3251e89c309142ffab52615dc6ade62ea2e 100644 --- a/src/Illuminate/Foundation/Console/stubs/rule.stub +++ b/src/Illuminate/Foundation/Console/stubs/rule.stub @@ -1,10 +1,10 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\{{ ruleType }}; -class DummyClass implements Rule +class {{ class }} implements {{ ruleType }} { /** * Create a new rule instance. diff --git a/src/Illuminate/Foundation/Console/stubs/test.stub b/src/Illuminate/Foundation/Console/stubs/test.stub index 381c6ec5cd09ad74751a8a7c6901a2c6db911a53..84c75cbfe98d41235805449fab120c8cdcfe51cc 100644 --- a/src/Illuminate/Foundation/Console/stubs/test.stub +++ b/src/Illuminate/Foundation/Console/stubs/test.stub @@ -1,19 +1,19 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Tests\TestCase; -class DummyClass extends TestCase +class {{ class }} extends TestCase { /** * A basic feature test example. * * @return void */ - public function testExample() + public function test_example() { $response = $this->get('/'); diff --git a/src/Illuminate/Foundation/Console/stubs/unit-test.stub b/src/Illuminate/Foundation/Console/stubs/test.unit.stub similarity index 63% rename from src/Illuminate/Foundation/Console/stubs/unit-test.stub rename to src/Illuminate/Foundation/Console/stubs/test.unit.stub index 4eb7c4a91ee7528d38f13f4639a5de845dff6946..b6816aa72f29de7020c9d86f726cdd7e5bc22c0c 100644 --- a/src/Illuminate/Foundation/Console/stubs/unit-test.stub +++ b/src/Illuminate/Foundation/Console/stubs/test.unit.stub @@ -1,17 +1,17 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use PHPUnit\Framework\TestCase; -class DummyClass extends TestCase +class {{ class }} extends TestCase { /** * A basic unit test example. * * @return void */ - public function testExample() + public function test_example() { $this->assertTrue(true); } diff --git a/src/Illuminate/Foundation/Console/stubs/view-component.stub b/src/Illuminate/Foundation/Console/stubs/view-component.stub new file mode 100644 index 0000000000000000000000000000000000000000..eab8fd353532dcc8e8584704120aa8af17371865 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/view-component.stub @@ -0,0 +1,28 @@ +<?php + +namespace {{ namespace }}; + +use Illuminate\View\Component; + +class {{ class }} extends Component +{ + /** + * Create a new component instance. + * + * @return void + */ + public function __construct() + { + // + } + + /** + * Get the view / contents that represent the component. + * + * @return \Illuminate\Contracts\View\View|\Closure|string + */ + public function render() + { + return {{ view }}; + } +} diff --git a/src/Illuminate/Foundation/Events/DiscoverEvents.php b/src/Illuminate/Foundation/Events/DiscoverEvents.php index 0fa87135c9ff167f9b4fa55aa554fe851d66330d..6d07df058b3f1e036f6a135983ff202426815d2a 100644 --- a/src/Illuminate/Foundation/Events/DiscoverEvents.php +++ b/src/Illuminate/Foundation/Events/DiscoverEvents.php @@ -21,11 +21,23 @@ class DiscoverEvents */ public static function within($listenerPath, $basePath) { - return collect(static::getListenerEvents( + $listeners = collect(static::getListenerEvents( (new Finder)->files()->in($listenerPath), $basePath - ))->mapToDictionary(function ($event, $listener) { - return [$event => $listener]; - })->all(); + )); + + $discoveredEvents = []; + + foreach ($listeners as $listener => $events) { + foreach ($events as $event) { + if (! isset($discoveredEvents[$event])) { + $discoveredEvents[$event] = []; + } + + $discoveredEvents[$event][] = $listener; + } + } + + return $discoveredEvents; } /** @@ -59,7 +71,7 @@ class DiscoverEvents } $listenerEvents[$listener->name.'@'.$method->name] = - Reflector::getParameterClassName($method->getParameters()[0]); + Reflector::getParameterClassNames($method->getParameters()[0]); } } diff --git a/src/Illuminate/Foundation/Events/Dispatchable.php b/src/Illuminate/Foundation/Events/Dispatchable.php index 0018295b676e0b63cd38a046c323b81f24112f33..ff633150f911a9af303d8d28885b71ba40beca79 100644 --- a/src/Illuminate/Foundation/Events/Dispatchable.php +++ b/src/Illuminate/Foundation/Events/Dispatchable.php @@ -14,6 +14,34 @@ trait Dispatchable return event(new static(...func_get_args())); } + /** + * Dispatch the event with the given arguments if the given truth test passes. + * + * @param bool $boolean + * @param mixed ...$arguments + * @return void + */ + public static function dispatchIf($boolean, ...$arguments) + { + if ($boolean) { + return event(new static(...$arguments)); + } + } + + /** + * Dispatch the event with the given arguments unless the given truth test passes. + * + * @param bool $boolean + * @param mixed ...$arguments + * @return void + */ + public static function dispatchUnless($boolean, ...$arguments) + { + if (! $boolean) { + return event(new static(...$arguments)); + } + } + /** * Broadcast the event with the given arguments. * diff --git a/src/Illuminate/Foundation/Events/MaintenanceModeDisabled.php b/src/Illuminate/Foundation/Events/MaintenanceModeDisabled.php new file mode 100644 index 0000000000000000000000000000000000000000..f8edf47d0042452aceaf576e0bc5eba8595278d7 --- /dev/null +++ b/src/Illuminate/Foundation/Events/MaintenanceModeDisabled.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Foundation\Events; + +class MaintenanceModeDisabled +{ + // +} diff --git a/src/Illuminate/Foundation/Events/MaintenanceModeEnabled.php b/src/Illuminate/Foundation/Events/MaintenanceModeEnabled.php new file mode 100644 index 0000000000000000000000000000000000000000..4d6cd0761dbda2e6f765580bb01c6b92a2023943 --- /dev/null +++ b/src/Illuminate/Foundation/Events/MaintenanceModeEnabled.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Foundation\Events; + +class MaintenanceModeEnabled +{ + // +} diff --git a/src/Illuminate/Foundation/Events/VendorTagPublished.php b/src/Illuminate/Foundation/Events/VendorTagPublished.php new file mode 100644 index 0000000000000000000000000000000000000000..084c1293fcfd074cc428f730085607023fdd8736 --- /dev/null +++ b/src/Illuminate/Foundation/Events/VendorTagPublished.php @@ -0,0 +1,33 @@ +<?php + +namespace Illuminate\Foundation\Events; + +class VendorTagPublished +{ + /** + * The vendor tag that was published. + * + * @var string + */ + public $tag; + + /** + * The publishable paths registered by the tag. + * + * @var array + */ + public $paths; + + /** + * Create a new event instance. + * + * @param string $tag + * @param array $paths + * @return void + */ + public function __construct($tag, $paths) + { + $this->tag = $tag; + $this->paths = $paths; + } +} diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index 98d888c56cf5b23edef77e3f60b31d9db5d609e7..444caf709c8d312ff3b5e24831e3742b3585cc01 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Exceptions; +use Closure; use Exception; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; @@ -10,6 +11,8 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Illuminate\Contracts\Support\Responsable; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\MultipleRecordsFoundException; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -18,14 +21,14 @@ use Illuminate\Routing\Router; use Illuminate\Session\TokenMismatchException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\View; use Illuminate\Support\Reflector; +use Illuminate\Support\Traits\ReflectsClosures; use Illuminate\Support\ViewErrorBag; use Illuminate\Validation\ValidationException; +use InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Application as ConsoleApplication; -use Symfony\Component\Debug\Exception\FlattenException; -use Symfony\Component\Debug\ExceptionHandler as SymfonyExceptionHandler; +use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -39,6 +42,8 @@ use Whoops\Run as Whoops; class Handler implements ExceptionHandlerContract { + use ReflectsClosures; + /** * The container implementation. * @@ -49,14 +54,35 @@ class Handler implements ExceptionHandlerContract /** * A list of the exception types that are not reported. * - * @var array + * @var string[] */ protected $dontReport = []; + /** + * The callbacks that should be used during reporting. + * + * @var \Illuminate\Foundation\Exceptions\ReportableHandler[] + */ + protected $reportCallbacks = []; + + /** + * The callbacks that should be used during rendering. + * + * @var \Closure[] + */ + protected $renderCallbacks = []; + + /** + * The registered exception mappings. + * + * @var array<string, \Closure> + */ + protected $exceptionMap = []; + /** * A list of the internal exception types that should not be reported. * - * @var array + * @var string[] */ protected $internalDontReport = [ AuthenticationException::class, @@ -64,6 +90,8 @@ class Handler implements ExceptionHandlerContract HttpException::class, HttpResponseException::class, ModelNotFoundException::class, + MultipleRecordsFoundException::class, + RecordsNotFoundException::class, SuspiciousOperationException::class, TokenMismatchException::class, ValidationException::class, @@ -72,9 +100,10 @@ class Handler implements ExceptionHandlerContract /** * A list of the inputs that are never flashed for validation exceptions. * - * @var array + * @var string[] */ protected $dontFlash = [ + 'current_password', 'password', 'password_confirmation', ]; @@ -88,24 +117,125 @@ class Handler implements ExceptionHandlerContract public function __construct(Container $container) { $this->container = $container; + + $this->register(); + } + + /** + * Register the exception handling callbacks for the application. + * + * @return void + */ + public function register() + { + // + } + + /** + * Register a reportable callback. + * + * @param callable $reportUsing + * @return \Illuminate\Foundation\Exceptions\ReportableHandler + */ + public function reportable(callable $reportUsing) + { + if (! $reportUsing instanceof Closure) { + $reportUsing = Closure::fromCallable($reportUsing); + } + + return tap(new ReportableHandler($reportUsing), function ($callback) { + $this->reportCallbacks[] = $callback; + }); + } + + /** + * Register a renderable callback. + * + * @param callable $renderUsing + * @return $this + */ + public function renderable(callable $renderUsing) + { + if (! $renderUsing instanceof Closure) { + $renderUsing = Closure::fromCallable($renderUsing); + } + + $this->renderCallbacks[] = $renderUsing; + + return $this; + } + + /** + * Register a new exception mapping. + * + * @param \Closure|string $from + * @param \Closure|string|null $to + * @return $this + * + * @throws \InvalidArgumentException + */ + public function map($from, $to = null) + { + if (is_string($to)) { + $to = function ($exception) use ($to) { + return new $to('', 0, $exception); + }; + } + + if (is_callable($from) && is_null($to)) { + $from = $this->firstClosureParameterType($to = $from); + } + + if (! is_string($from) || ! $to instanceof Closure) { + throw new InvalidArgumentException('Invalid exception mapping.'); + } + + $this->exceptionMap[$from] = $to; + + return $this; + } + + /** + * Indicate that the given exception type should not be reported. + * + * @param string $class + * @return $this + */ + protected function ignore(string $class) + { + $this->dontReport[] = $class; + + return $this; } /** * Report or log an exception. * - * @param \Exception $e + * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - public function report(Exception $e) + public function report(Throwable $e) { + $e = $this->mapException($e); + if ($this->shouldntReport($e)) { return; } if (Reflector::isCallable($reportCallable = [$e, 'report'])) { - return $this->container->call($reportCallable); + if ($this->container->call($reportCallable) !== false) { + return; + } + } + + foreach ($this->reportCallbacks as $reportCallback) { + if ($reportCallback->handles($e)) { + if ($reportCallback($e) === false) { + return; + } + } } try { @@ -127,10 +257,10 @@ class Handler implements ExceptionHandlerContract /** * Determine if the exception should be reported. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - public function shouldReport(Exception $e) + public function shouldReport(Throwable $e) { return ! $this->shouldntReport($e); } @@ -138,10 +268,10 @@ class Handler implements ExceptionHandlerContract /** * Determine if the exception is in the "do not report" list. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - protected function shouldntReport(Exception $e) + protected function shouldntReport(Throwable $e) { $dontReport = array_merge($this->dontReport, $this->internalDontReport); @@ -153,11 +283,15 @@ class Handler implements ExceptionHandlerContract /** * Get the default exception context variables for logging. * - * @param \Exception $e + * @param \Throwable $e * @return array */ - protected function exceptionContext(Exception $e) + protected function exceptionContext(Throwable $e) { + if (method_exists($e, 'context')) { + return $e->context(); + } + return []; } @@ -182,12 +316,12 @@ class Handler implements ExceptionHandlerContract * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response * - * @throws \Exception + * @throws \Throwable */ - public function render($request, Exception $e) + public function render($request, Throwable $e) { if (method_exists($e, 'render') && $response = $e->render($request)) { return Router::toResponse($request, $response); @@ -195,7 +329,19 @@ class Handler implements ExceptionHandlerContract return $e->toResponse($request); } - $e = $this->prepareException($e); + $e = $this->prepareException($this->mapException($e)); + + foreach ($this->renderCallbacks as $renderCallback) { + foreach ($this->firstClosureParameterTypes($renderCallback) as $type) { + if (is_a($e, $type)) { + $response = $renderCallback($e, $request); + + if (! is_null($response)) { + return $response; + } + } + } + } if ($e instanceof HttpResponseException) { return $e->getResponse(); @@ -205,18 +351,35 @@ class Handler implements ExceptionHandlerContract return $this->convertValidationExceptionToResponse($e, $request); } - return $request->expectsJson() + return $this->shouldReturnJson($request, $e) ? $this->prepareJsonResponse($request, $e) : $this->prepareResponse($request, $e); } + /** + * Map the exception using a registered mapper if possible. + * + * @param \Throwable $e + * @return \Throwable + */ + protected function mapException(Throwable $e) + { + foreach ($this->exceptionMap as $class => $mapper) { + if (is_a($e, $class)) { + return $mapper($e); + } + } + + return $e; + } + /** * Prepare exception for rendering. * - * @param \Exception $e - * @return \Exception + * @param \Throwable $e + * @return \Throwable */ - protected function prepareException(Exception $e) + protected function prepareException(Throwable $e) { if ($e instanceof ModelNotFoundException) { $e = new NotFoundHttpException($e->getMessage(), $e); @@ -226,6 +389,8 @@ class Handler implements ExceptionHandlerContract $e = new HttpException(419, $e->getMessage(), $e); } elseif ($e instanceof SuspiciousOperationException) { $e = new NotFoundHttpException('Bad hostname provided.', $e); + } elseif ($e instanceof RecordsNotFoundException) { + $e = new NotFoundHttpException('Not found.', $e); } return $e; @@ -240,7 +405,7 @@ class Handler implements ExceptionHandlerContract */ protected function unauthenticated($request, AuthenticationException $exception) { - return $request->expectsJson() + return $this->shouldReturnJson($request, $exception) ? response()->json(['message' => $exception->getMessage()], 401) : redirect()->guest($exception->redirectTo() ?? route('login')); } @@ -258,7 +423,7 @@ class Handler implements ExceptionHandlerContract return $e->response; } - return $request->expectsJson() + return $this->shouldReturnJson($request, $e) ? $this->invalidJson($request, $e) : $this->invalid($request, $e); } @@ -274,7 +439,7 @@ class Handler implements ExceptionHandlerContract { return redirect($exception->redirectTo ?? url()->previous()) ->withInput(Arr::except($request->input(), $this->dontFlash)) - ->withErrors($exception->errors(), $exception->errorBag); + ->withErrors($exception->errors(), $request->input('_error_bag', $exception->errorBag)); } /** @@ -292,14 +457,26 @@ class Handler implements ExceptionHandlerContract ], $exception->status); } + /** + * Determine if the exception handler response should be JSON. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $e + * @return bool + */ + protected function shouldReturnJson($request, Throwable $e) + { + return $request->expectsJson(); + } + /** * Prepare a response for the given exception. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response */ - protected function prepareResponse($request, Exception $e) + protected function prepareResponse($request, Throwable $e) { if (! $this->isHttpException($e) && config('app.debug')) { return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e); @@ -317,10 +494,10 @@ class Handler implements ExceptionHandlerContract /** * Create a Symfony response for the given exception. * - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response */ - protected function convertExceptionToResponse(Exception $e) + protected function convertExceptionToResponse(Throwable $e) { return new SymfonyResponse( $this->renderExceptionContent($e), @@ -332,10 +509,10 @@ class Handler implements ExceptionHandlerContract /** * Get the response content for the given exception. * - * @param \Exception $e + * @param \Throwable $e * @return string */ - protected function renderExceptionContent(Exception $e) + protected function renderExceptionContent(Throwable $e) { try { return config('app.debug') && class_exists(Whoops::class) @@ -349,10 +526,10 @@ class Handler implements ExceptionHandlerContract /** * Render an exception to a string using "Whoops". * - * @param \Exception $e + * @param \Throwable $e * @return string */ - protected function renderExceptionWithWhoops(Exception $e) + protected function renderExceptionWithWhoops(Throwable $e) { return tap(new Whoops, function ($whoops) { $whoops->appendHandler($this->whoopsHandler()); @@ -380,15 +557,15 @@ class Handler implements ExceptionHandlerContract /** * Render an exception to a string using Symfony. * - * @param \Exception $e + * @param \Throwable $e * @param bool $debug * @return string */ - protected function renderExceptionWithSymfony(Exception $e, $debug) + protected function renderExceptionWithSymfony(Throwable $e, $debug) { - return (new SymfonyExceptionHandler($debug))->getHtml( - FlattenException::create($e) - ); + $renderer = new HtmlErrorRenderer($debug); + + return $renderer->render($e)->getAsString(); } /** @@ -418,11 +595,7 @@ class Handler implements ExceptionHandlerContract */ protected function registerErrorViewPaths() { - $paths = collect(config('view.paths')); - - View::replaceNamespace('errors', $paths->map(function ($path) { - return "{$path}/errors"; - })->push(__DIR__.'/views')->all()); + (new RegisterErrorViewPaths)(); } /** @@ -440,10 +613,10 @@ class Handler implements ExceptionHandlerContract * Map the given exception into an Illuminate response. * * @param \Symfony\Component\HttpFoundation\Response $response - * @param \Exception $e + * @param \Throwable $e * @return \Illuminate\Http\Response */ - protected function toIlluminateResponse($response, Exception $e) + protected function toIlluminateResponse($response, Throwable $e) { if ($response instanceof SymfonyRedirectResponse) { $response = new RedirectResponse( @@ -462,10 +635,10 @@ class Handler implements ExceptionHandlerContract * Prepare a JSON response for the given exception. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Illuminate\Http\JsonResponse */ - protected function prepareJsonResponse($request, Exception $e) + protected function prepareJsonResponse($request, Throwable $e) { return new JsonResponse( $this->convertExceptionToArray($e), @@ -478,10 +651,10 @@ class Handler implements ExceptionHandlerContract /** * Convert the given exception to an array. * - * @param \Exception $e + * @param \Throwable $e * @return array */ - protected function convertExceptionToArray(Exception $e) + protected function convertExceptionToArray(Throwable $e) { return config('app.debug') ? [ 'message' => $e->getMessage(), @@ -500,21 +673,21 @@ class Handler implements ExceptionHandlerContract * Render an exception to the console. * * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param \Exception $e + * @param \Throwable $e * @return void */ - public function renderForConsole($output, Exception $e) + public function renderForConsole($output, Throwable $e) { - (new ConsoleApplication)->renderException($e, $output); + (new ConsoleApplication)->renderThrowable($e, $output); } /** * Determine if the given exception is an HTTP exception. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - protected function isHttpException(Exception $e) + protected function isHttpException(Throwable $e) { return $e instanceof HttpExceptionInterface; } diff --git a/src/Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php b/src/Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php new file mode 100644 index 0000000000000000000000000000000000000000..c88f3261a9420d94821e4133eb94a137b40e6aaf --- /dev/null +++ b/src/Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php @@ -0,0 +1,20 @@ +<?php + +namespace Illuminate\Foundation\Exceptions; + +use Illuminate\Support\Facades\View; + +class RegisterErrorViewPaths +{ + /** + * Register the error view paths. + * + * @return void + */ + public function __invoke() + { + View::replaceNamespace('errors', collect(config('view.paths'))->map(function ($path) { + return "{$path}/errors"; + })->push(__DIR__.'/views')->all()); + } +} diff --git a/src/Illuminate/Foundation/Exceptions/ReportableHandler.php b/src/Illuminate/Foundation/Exceptions/ReportableHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..06a6172f5c03f990573a184bf164a6083f4b6066 --- /dev/null +++ b/src/Illuminate/Foundation/Exceptions/ReportableHandler.php @@ -0,0 +1,82 @@ +<?php + +namespace Illuminate\Foundation\Exceptions; + +use Illuminate\Support\Traits\ReflectsClosures; +use Throwable; + +class ReportableHandler +{ + use ReflectsClosures; + + /** + * The underlying callback. + * + * @var callable + */ + protected $callback; + + /** + * Indicates if reporting should stop after invoking this handler. + * + * @var bool + */ + protected $shouldStop = false; + + /** + * Create a new reportable handler instance. + * + * @param callable $callback + * @return void + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + /** + * Invoke the handler. + * + * @param \Throwable $e + * @return bool + */ + public function __invoke(Throwable $e) + { + $result = call_user_func($this->callback, $e); + + if ($result === false) { + return false; + } + + return ! $this->shouldStop; + } + + /** + * Determine if the callback handles the given exception. + * + * @param \Throwable $e + * @return bool + */ + public function handles(Throwable $e) + { + foreach ($this->firstClosureParameterTypes($this->callback) as $type) { + if (is_a($e, $type)) { + return true; + } + } + + return false; + } + + /** + * Indicate that report handling should stop after invoking this callback. + * + * @return $this + */ + public function stop() + { + $this->shouldStop = true; + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Exceptions/WhoopsHandler.php b/src/Illuminate/Foundation/Exceptions/WhoopsHandler.php index 2137f29bafd6a80b594db91b782c7aa9032386cc..c91d1ac7b7ae3e47a2611963831359a38753875d 100644 --- a/src/Illuminate/Foundation/Exceptions/WhoopsHandler.php +++ b/src/Illuminate/Foundation/Exceptions/WhoopsHandler.php @@ -60,7 +60,7 @@ class WhoopsHandler */ protected function registerBlacklist($handler) { - foreach (config('app.debug_blacklist', []) as $key => $secrets) { + foreach (config('app.debug_blacklist', config('app.debug_hide', [])) as $key => $secrets) { foreach ($secrets as $secret) { $handler->blacklist($key, $secret); } diff --git a/src/Illuminate/Foundation/Exceptions/views/503.blade.php b/src/Illuminate/Foundation/Exceptions/views/503.blade.php index acd38100a745ea5888e967130dda01d9e738205a..c5a9dde14e4835847ac9d152ff214eb88c162750 100644 --- a/src/Illuminate/Foundation/Exceptions/views/503.blade.php +++ b/src/Illuminate/Foundation/Exceptions/views/503.blade.php @@ -2,4 +2,4 @@ @section('title', __('Service Unavailable')) @section('code', '503') -@section('message', __($exception->getMessage() ?: 'Service Unavailable')) +@section('message', __('Service Unavailable')) diff --git a/src/Illuminate/Foundation/Exceptions/views/illustrated-layout.blade.php b/src/Illuminate/Foundation/Exceptions/views/illustrated-layout.blade.php index 64eb7cbb8bd5342556b9c02ddc1fe5afcbebe1d3..2e5b8240b59b387d7adc96d1292a0913b1b745b9 100644 --- a/src/Illuminate/Foundation/Exceptions/views/illustrated-layout.blade.php +++ b/src/Illuminate/Foundation/Exceptions/views/illustrated-layout.blade.php @@ -7,8 +7,8 @@ <title>@yield('title')</title> <!-- Fonts --> - <link rel="dns-prefetch" href="//fonts.gstatic.com"> - <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet"> + <link rel="preconnect" href="https://fonts.gstatic.com"> + <link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet"> <!-- Styles --> <style> diff --git a/src/Illuminate/Foundation/Exceptions/views/layout.blade.php b/src/Illuminate/Foundation/Exceptions/views/layout.blade.php index 2c51d4f355b5a212659744043a6b72a809d45729..4f2318f8a69360d447d229dd619f290f9678afd5 100644 --- a/src/Illuminate/Foundation/Exceptions/views/layout.blade.php +++ b/src/Illuminate/Foundation/Exceptions/views/layout.blade.php @@ -7,8 +7,8 @@ <title>@yield('title')</title> <!-- Fonts --> - <link rel="dns-prefetch" href="//fonts.gstatic.com"> - <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet" type="text/css"> + <link rel="preconnect" href="https://fonts.gstatic.com"> + <link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet"> <!-- Styles --> <style> diff --git a/src/Illuminate/Foundation/Exceptions/views/minimal.blade.php b/src/Illuminate/Foundation/Exceptions/views/minimal.blade.php index b63ac2b3724c42ecc30c4db1546b5519a6cf8ffa..ee16d444584b2d9fa46f69cbca999752f01488e7 100644 --- a/src/Illuminate/Foundation/Exceptions/views/minimal.blade.php +++ b/src/Illuminate/Foundation/Exceptions/views/minimal.blade.php @@ -7,55 +7,31 @@ <title>@yield('title')</title> <!-- Fonts --> - <link rel="dns-prefetch" href="//fonts.gstatic.com"> - <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet"> + <link rel="preconnect" href="https://fonts.gstatic.com"> + <link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet"> - <!-- Styles --> <style> - html, body { - background-color: #fff; - color: #636b6f; - font-family: 'Nunito', sans-serif; - font-weight: 100; - height: 100vh; - margin: 0; - } - - .full-height { - height: 100vh; - } - - .flex-center { - align-items: center; - display: flex; - justify-content: center; - } - - .position-ref { - position: relative; - } - - .code { - border-right: 2px solid; - font-size: 26px; - padding: 0 15px 0 15px; - text-align: center; - } + /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}a{background-color:transparent}code{font-family:monospace,monospace;font-size:1em}[hidden]{display:none}html{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #e2e8f0}a{color:inherit;text-decoration:inherit}code{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}svg,video{display:block;vertical-align:middle}video{max-width:100%;height:auto}.bg-white{--bg-opacity:1;background-color:#fff;background-color:rgba(255,255,255,var(--bg-opacity))}.bg-gray-100{--bg-opacity:1;background-color:#f7fafc;background-color:rgba(247,250,252,var(--bg-opacity))}.border-gray-200{--border-opacity:1;border-color:#edf2f7;border-color:rgba(237,242,247,var(--border-opacity))}.border-gray-400{--border-opacity:1;border-color:#cbd5e0;border-color:rgba(203,213,224,var(--border-opacity))}.border-t{border-top-width:1px}.border-r{border-right-width:1px}.flex{display:flex}.grid{display:grid}.hidden{display:none}.items-center{align-items:center}.justify-center{justify-content:center}.font-semibold{font-weight:600}.h-5{height:1.25rem}.h-8{height:2rem}.h-16{height:4rem}.text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}.leading-7{line-height:1.75rem}.mx-auto{margin-left:auto;margin-right:auto}.ml-1{margin-left:.25rem}.mt-2{margin-top:.5rem}.mr-2{margin-right:.5rem}.ml-2{margin-left:.5rem}.mt-4{margin-top:1rem}.ml-4{margin-left:1rem}.mt-8{margin-top:2rem}.ml-12{margin-left:3rem}.-mt-px{margin-top:-1px}.max-w-xl{max-width:36rem}.max-w-6xl{max-width:72rem}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.p-6{padding:1.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pt-8{padding-top:2rem}.fixed{position:fixed}.relative{position:relative}.top-0{top:0}.right-0{right:0}.shadow{box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px 0 rgba(0,0,0,.06)}.text-center{text-align:center}.text-gray-200{--text-opacity:1;color:#edf2f7;color:rgba(237,242,247,var(--text-opacity))}.text-gray-300{--text-opacity:1;color:#e2e8f0;color:rgba(226,232,240,var(--text-opacity))}.text-gray-400{--text-opacity:1;color:#cbd5e0;color:rgba(203,213,224,var(--text-opacity))}.text-gray-500{--text-opacity:1;color:#a0aec0;color:rgba(160,174,192,var(--text-opacity))}.text-gray-600{--text-opacity:1;color:#718096;color:rgba(113,128,150,var(--text-opacity))}.text-gray-700{--text-opacity:1;color:#4a5568;color:rgba(74,85,104,var(--text-opacity))}.text-gray-900{--text-opacity:1;color:#1a202c;color:rgba(26,32,44,var(--text-opacity))}.uppercase{text-transform:uppercase}.underline{text-decoration:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.tracking-wider{letter-spacing:.05em}.w-5{width:1.25rem}.w-8{width:2rem}.w-auto{width:auto}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}@-webkit-keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}@-webkit-keyframes ping{0%{transform:scale(1);opacity:1}75%,to{transform:scale(2);opacity:0}}@keyframes ping{0%{transform:scale(1);opacity:1}75%,to{transform:scale(2);opacity:0}}@-webkit-keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}@-webkit-keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:translateY(0);-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:translateY(0);-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@media (min-width:640px){.sm\:rounded-lg{border-radius:.5rem}.sm\:block{display:block}.sm\:items-center{align-items:center}.sm\:justify-start{justify-content:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:h-20{height:5rem}.sm\:ml-0{margin-left:0}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:pt-0{padding-top:0}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}}@media (min-width:768px){.md\:border-t-0{border-top-width:0}.md\:border-l{border-left-width:1px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--bg-opacity:1;background-color:#2d3748;background-color:rgba(45,55,72,var(--bg-opacity))}.dark\:bg-gray-900{--bg-opacity:1;background-color:#1a202c;background-color:rgba(26,32,44,var(--bg-opacity))}.dark\:border-gray-700{--border-opacity:1;border-color:#4a5568;border-color:rgba(74,85,104,var(--border-opacity))}.dark\:text-white{--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.dark\:text-gray-400{--text-opacity:1;color:#cbd5e0;color:rgba(203,213,224,var(--text-opacity))}} + </style> - .message { - font-size: 18px; - text-align: center; + <style> + body { + font-family: 'Nunito', sans-serif; } </style> </head> - <body> - <div class="flex-center position-ref full-height"> - <div class="code"> - @yield('code') - </div> - - <div class="message" style="padding: 10px;"> - @yield('message') + <body class="antialiased"> + <div class="relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center sm:pt-0"> + <div class="max-w-xl mx-auto sm:px-6 lg:px-8"> + <div class="flex items-center pt-8 sm:justify-start sm:pt-0"> + <div class="px-4 text-lg text-gray-500 border-r border-gray-400 tracking-wider"> + @yield('code') + </div> + + <div class="ml-4 text-lg text-gray-500 uppercase tracking-wider"> + @yield('message') + </div> + </div> </div> </div> </body> diff --git a/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php b/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php index 45c76b2201b670a4aa96a82077e09845db1c045d..5553fde625d2360434d498cc6a2f6fc50dca1e75 100644 --- a/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php +++ b/src/Illuminate/Foundation/Http/Exceptions/MaintenanceModeException.php @@ -2,11 +2,14 @@ namespace Illuminate\Foundation\Http\Exceptions; -use Exception; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Throwable; +/** + * @deprecated Will be removed in a future Laravel version. + */ class MaintenanceModeException extends ServiceUnavailableHttpException { /** @@ -36,11 +39,11 @@ class MaintenanceModeException extends ServiceUnavailableHttpException * @param int $time * @param int|null $retryAfter * @param string|null $message - * @param \Exception|null $previous + * @param \Throwable|null $previous * @param int $code * @return void */ - public function __construct($time, $retryAfter = null, $message = null, Exception $previous = null, $code = 0) + public function __construct($time, $retryAfter = null, $message = null, Throwable $previous = null, $code = 0) { parent::__construct($retryAfter, $message, $previous, $code); diff --git a/src/Illuminate/Foundation/Http/FormRequest.php b/src/Illuminate/Foundation/Http/FormRequest.php index 96169f3ce40afa97f43a5ac1d0d2da04caf40280..a20dffe2fbe7bd1c7991993b3b7856e3f55b7483 100644 --- a/src/Illuminate/Foundation/Http/FormRequest.php +++ b/src/Illuminate/Foundation/Http/FormRequest.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Http; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Validation\Factory as ValidationFactory; use Illuminate\Contracts\Validation\ValidatesWhenResolved; @@ -58,6 +59,13 @@ class FormRequest extends Request implements ValidatesWhenResolved */ protected $errorBag = 'default'; + /** + * Indicates whether validation should stop after the first rule failure. + * + * @var bool + */ + protected $stopOnFirstFailure = false; + /** * The validator instance. * @@ -104,7 +112,7 @@ class FormRequest extends Request implements ValidatesWhenResolved return $factory->make( $this->validationData(), $this->container->call([$this, 'rules']), $this->messages(), $this->attributes() - ); + )->stopOnFirstFailure($this->stopOnFirstFailure); } /** @@ -156,11 +164,15 @@ class FormRequest extends Request implements ValidatesWhenResolved * Determine if the request passes the authorization check. * * @return bool + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ protected function passesAuthorization() { if (method_exists($this, 'authorize')) { - return $this->container->call([$this, 'authorize']); + $result = $this->container->call([$this, 'authorize']); + + return $result instanceof Response ? $result->authorize() : $result; } return true; @@ -178,6 +190,19 @@ class FormRequest extends Request implements ValidatesWhenResolved throw new AuthorizationException; } + /** + * Get a validated input container for the validated input. + * + * @param array|null $keys + * @return \Illuminate\Support\ValidatedInput|array + */ + public function safe(array $keys = null) + { + return is_array($keys) + ? $this->validator->safe()->only($keys) + : $this->validator->safe(); + } + /** * Get the validated data from the request. * diff --git a/src/Illuminate/Foundation/Http/Kernel.php b/src/Illuminate/Foundation/Http/Kernel.php index 01de7edef874b9bd65a399f8830c2d74515016ca..fa8d9aad33f273c43660e5b6a54d6b88fcf157e6 100644 --- a/src/Illuminate/Foundation/Http/Kernel.php +++ b/src/Illuminate/Foundation/Http/Kernel.php @@ -2,7 +2,6 @@ namespace Illuminate\Foundation\Http; -use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Http\Kernel as KernelContract; @@ -11,7 +10,6 @@ use Illuminate\Routing\Pipeline; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Facade; use InvalidArgumentException; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Throwable; class Kernel implements KernelContract @@ -33,7 +31,7 @@ class Kernel implements KernelContract /** * The bootstrap classes for the application. * - * @var array + * @var string[] */ protected $bootstrappers = [ \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class, @@ -70,12 +68,15 @@ class Kernel implements KernelContract * * Forces non-global middleware to always be in the given order. * - * @var array + * @var string[] */ protected $middlewarePriority = [ + \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \Illuminate\Auth\Middleware\Authenticate::class, + \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, + \Illuminate\Routing\Middleware\ThrottleRequests::class, + \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, @@ -108,12 +109,8 @@ class Kernel implements KernelContract $request->enableHttpMethodParameterOverride(); $response = $this->sendRequestThroughRouter($request); - } catch (Exception $e) { - $this->reportException($e); - - $response = $this->renderException($request, $e); } catch (Throwable $e) { - $this->reportException($e = new FatalThrowableError($e)); + $this->reportException($e); $response = $this->renderException($request, $e); } @@ -258,7 +255,7 @@ class Kernel implements KernelContract } /** - * Add a new middleware to beginning of the stack if it does not already exist. + * Add a new middleware to the beginning of the stack if it does not already exist. * * @param string $middleware * @return $this @@ -387,6 +384,16 @@ class Kernel implements KernelContract } } + /** + * Get the priority-sorted list of middleware. + * + * @return array + */ + public function getMiddlewarePriority() + { + return $this->middlewarePriority; + } + /** * Get the bootstrap classes for the application. * @@ -400,10 +407,10 @@ class Kernel implements KernelContract /** * Report the exception to the exception handler. * - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function reportException(Exception $e) + protected function reportException(Throwable $e) { $this->app[ExceptionHandler::class]->report($e); } @@ -412,10 +419,10 @@ class Kernel implements KernelContract * Render the exception to a response. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response */ - protected function renderException($request, Exception $e) + protected function renderException($request, Throwable $e) { return $this->app[ExceptionHandler::class]->render($request, $e); } @@ -449,4 +456,17 @@ class Kernel implements KernelContract { return $this->app; } + + /** + * Set the Laravel application instance. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication(Application $app) + { + $this->app = $app; + + return $this; + } } diff --git a/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php b/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php new file mode 100644 index 0000000000000000000000000000000000000000..ecb6fb95eea064f084a57eaa924e5ba6ba486a16 --- /dev/null +++ b/src/Illuminate/Foundation/Http/MaintenanceModeBypassCookie.php @@ -0,0 +1,43 @@ +<?php + +namespace Illuminate\Foundation\Http; + +use Illuminate\Support\Carbon; +use Symfony\Component\HttpFoundation\Cookie; + +class MaintenanceModeBypassCookie +{ + /** + * Create a new maintenance mode bypass cookie. + * + * @param string $key + * @return \Symfony\Component\HttpFoundation\Cookie + */ + public static function create(string $key) + { + $expiresAt = Carbon::now()->addHours(12); + + return new Cookie('laravel_maintenance', base64_encode(json_encode([ + 'expires_at' => $expiresAt->getTimestamp(), + 'mac' => hash_hmac('sha256', $expiresAt->getTimestamp(), $key), + ])), $expiresAt); + } + + /** + * Determine if the given maintenance mode bypass cookie is valid. + * + * @param string $cookie + * @param string $key + * @return bool + */ + public static function isValid(string $cookie, string $key) + { + $payload = json_decode(base64_decode($cookie), true); + + return is_array($payload) && + is_numeric($payload['expires_at'] ?? null) && + isset($payload['mac']) && + hash_equals(hash_hmac('sha256', $payload['expires_at'], $key), $payload['mac']) && + (int) $payload['expires_at'] >= Carbon::now()->getTimestamp(); + } +} diff --git a/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php b/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php index 5a34d1860e6f4973f98c2c81ba58304d4cf83c03..01a14b44bec0cd52b78d93d7e3f3b4ade1bc5320 100644 --- a/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php +++ b/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php @@ -2,85 +2,7 @@ namespace Illuminate\Foundation\Http\Middleware; -use Closure; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Foundation\Http\Exceptions\MaintenanceModeException; -use Symfony\Component\HttpFoundation\IpUtils; - -class CheckForMaintenanceMode +class CheckForMaintenanceMode extends PreventRequestsDuringMaintenance { - /** - * The application implementation. - * - * @var \Illuminate\Contracts\Foundation\Application - */ - protected $app; - - /** - * The URIs that should be accessible while maintenance mode is enabled. - * - * @var array - */ - protected $except = []; - - /** - * Create a new middleware instance. - * - * @param \Illuminate\Contracts\Foundation\Application $app - * @return void - */ - public function __construct(Application $app) - { - $this->app = $app; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed - * - * @throws \Symfony\Component\HttpKernel\Exception\HttpException - * @throws \Illuminate\Foundation\Http\Exceptions\MaintenanceModeException - */ - public function handle($request, Closure $next) - { - if ($this->app->isDownForMaintenance()) { - $data = json_decode(file_get_contents($this->app->storagePath().'/framework/down'), true); - - if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) { - return $next($request); - } - - if ($this->inExceptArray($request)) { - return $next($request); - } - - throw new MaintenanceModeException($data['time'], $data['retry'], $data['message']); - } - - return $next($request); - } - - /** - * Determine if the request has a URI that should be accessible in maintenance mode. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - protected function inExceptArray($request) - { - foreach ($this->except as $except) { - if ($except !== '/') { - $except = trim($except, '/'); - } - - if ($request->fullUrlIs($except) || $request->is($except)) { - return true; - } - } - - return false; - } + // } diff --git a/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php b/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php index 813c9cf123ce376b5934ffb3fc0bb4cba7b350a3..d19a07fa493b313aea8eadc74e04ff35b483dee0 100644 --- a/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php +++ b/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php @@ -2,8 +2,35 @@ namespace Illuminate\Foundation\Http\Middleware; +use Closure; + class ConvertEmptyStringsToNull extends TransformsRequest { + /** + * All of the registered skip callbacks. + * + * @var array + */ + protected static $skipCallbacks = []; + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + foreach (static::$skipCallbacks as $callback) { + if ($callback($request)) { + return $next($request); + } + } + + return parent::handle($request, $next); + } + /** * Transform the given value. * @@ -15,4 +42,15 @@ class ConvertEmptyStringsToNull extends TransformsRequest { return is_string($value) && $value === '' ? null : $value; } + + /** + * Register a callback that instructs the middleware to be skipped. + * + * @param \Closure $callback + * @return void + */ + public static function skipWhen(Closure $callback) + { + static::$skipCallbacks[] = $callback; + } } diff --git a/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php b/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 0000000000000000000000000000000000000000..a8692bc4f7e3936c4f95e964859c16e5c0d290e6 --- /dev/null +++ b/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,166 @@ +<?php + +namespace Illuminate\Foundation\Http\Middleware; + +use Closure; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Http\MaintenanceModeBypassCookie; +use Symfony\Component\HttpKernel\Exception\HttpException; + +class PreventRequestsDuringMaintenance +{ + /** + * The application implementation. + * + * @var \Illuminate\Contracts\Foundation\Application + */ + protected $app; + + /** + * The URIs that should be accessible while maintenance mode is enabled. + * + * @var array + */ + protected $except = []; + + /** + * Create a new middleware instance. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return void + */ + public function __construct(Application $app) + { + $this->app = $app; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function handle($request, Closure $next) + { + if ($this->app->isDownForMaintenance()) { + $data = json_decode(file_get_contents($this->app->storagePath().'/framework/down'), true); + + if (isset($data['secret']) && $request->path() === $data['secret']) { + return $this->bypassResponse($data['secret']); + } + + if ($this->hasValidBypassCookie($request, $data) || + $this->inExceptArray($request)) { + return $next($request); + } + + if (isset($data['redirect'])) { + $path = $data['redirect'] === '/' + ? $data['redirect'] + : trim($data['redirect'], '/'); + + if ($request->path() !== $path) { + return redirect($path); + } + } + + if (isset($data['template'])) { + return response( + $data['template'], + $data['status'] ?? 503, + $this->getHeaders($data) + ); + } + + throw new HttpException( + $data['status'] ?? 503, + 'Service Unavailable', + null, + $this->getHeaders($data) + ); + } + + return $next($request); + } + + /** + * Determine if the incoming request has a maintenance mode bypass cookie. + * + * @param \Illuminate\Http\Request $request + * @param array $data + * @return bool + */ + protected function hasValidBypassCookie($request, array $data) + { + return isset($data['secret']) && + $request->cookie('laravel_maintenance') && + MaintenanceModeBypassCookie::isValid( + $request->cookie('laravel_maintenance'), + $data['secret'] + ); + } + + /** + * Determine if the request has a URI that should be accessible in maintenance mode. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function inExceptArray($request) + { + foreach ($this->except as $except) { + if ($except !== '/') { + $except = trim($except, '/'); + } + + if ($request->fullUrlIs($except) || $request->is($except)) { + return true; + } + } + + return false; + } + + /** + * Redirect the user back to the root of the application with a maintenance mode bypass cookie. + * + * @param string $secret + * @return \Illuminate\Http\RedirectResponse + */ + protected function bypassResponse(string $secret) + { + return redirect('/')->withCookie( + MaintenanceModeBypassCookie::create($secret) + ); + } + + /** + * Get the headers that should be sent with the response. + * + * @param array $data + * @return array + */ + protected function getHeaders($data) + { + $headers = isset($data['retry']) ? ['Retry-After' => $data['retry']] : []; + + if (isset($data['refresh'])) { + $headers['Refresh'] = $data['refresh']; + } + + return $headers; + } + + /** + * Get the URIs that should be accessible even when maintenance mode is enabled. + * + * @return array + */ + public function getExcludedPaths() + { + return $this->except; + } +} diff --git a/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php b/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php index a61a1bd72013538623e02dcbd7aee744aaa75ac0..fca34f837b0b006933fa06163c6927314da2a0f9 100644 --- a/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php +++ b/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php @@ -58,9 +58,11 @@ class TransformsRequest */ protected function cleanArray(array $data, $keyPrefix = '') { - return collect($data)->map(function ($value, $key) use ($keyPrefix) { - return $this->cleanValue($keyPrefix.$key, $value); - })->all(); + foreach ($data as $key => $value) { + $data[$key] = $this->cleanValue($keyPrefix.$key, $value); + } + + return collect($data)->all(); } /** diff --git a/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php b/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php index 4c8d1ddba7526bf86a268ee313c2482cbe23d86a..fe8f8f8720432b2c9c8a02281e4f99f67a385007 100644 --- a/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php +++ b/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php @@ -2,8 +2,17 @@ namespace Illuminate\Foundation\Http\Middleware; +use Closure; + class TrimStrings extends TransformsRequest { + /** + * All of the registered skip callbacks. + * + * @var array + */ + protected static $skipCallbacks = []; + /** * The attributes that should not be trimmed. * @@ -13,6 +22,24 @@ class TrimStrings extends TransformsRequest // ]; + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + foreach (static::$skipCallbacks as $callback) { + if ($callback($request)) { + return $next($request); + } + } + + return parent::handle($request, $next); + } + /** * Transform the given value. * @@ -28,4 +55,15 @@ class TrimStrings extends TransformsRequest return is_string($value) ? trim($value) : $value; } + + /** + * Register a callback that instructs the middleware to be skipped. + * + * @param \Closure $callback + * @return void + */ + public static function skipWhen(Closure $callback) + { + static::$skipCallbacks[] = $callback; + } } diff --git a/src/Illuminate/Foundation/Inspiring.php b/src/Illuminate/Foundation/Inspiring.php index 78504fd63785e18a080e553107958dd1bb75bb05..0ca44fd625b3d6f215ce1f386f2386b439ca6d78 100644 --- a/src/Illuminate/Foundation/Inspiring.php +++ b/src/Illuminate/Foundation/Inspiring.php @@ -20,30 +20,42 @@ class Inspiring public static function quote() { return Collection::make([ - 'When there is no desire, all things are at peace. - Laozi', - 'Simplicity is the ultimate sophistication. - Leonardo da Vinci', - 'Simplicity is the essence of happiness. - Cedric Bledsoe', - 'Smile, breathe, and go slowly. - Thich Nhat Hanh', - 'Simplicity is an acquired taste. - Katharine Gerould', - 'Well begun is half done. - Aristotle', - 'He who is contented is rich. - Laozi', - 'Very little is needed to make a happy life. - Marcus Antoninus', - 'It is quality rather than quantity that matters. - Lucius Annaeus Seneca', 'Act only according to that maxim whereby you can, at the same time, will that it should become a universal law. - Immanuel Kant', - 'Knowing is not enough; we must apply. Being willing is not enough; we must do. - Leonardo da Vinci', 'An unexamined life is not worth living. - Socrates', + 'Be present above all else. - Naval Ravikant', + 'Do what you can, with what you have, where you are. - Theodore Roosevelt', 'Happiness is not something readymade. It comes from your own actions. - Dalai Lama', - 'The only way to do great work is to love what you do. - Steve Jobs', - 'The whole future lies in uncertainty: live immediately. - Seneca', - 'Waste no more time arguing what a good man should be, be one. - Marcus Aurelius', + 'He who is contented is rich. - Laozi', + 'I begin to speak only when I am certain what I will say is not better left unsaid. - Cato the Younger', + 'I have not failed. I\'ve just found 10,000 ways that won\'t work. - Thomas Edison', + 'If you do not have a consistent goal in life, you can not live it in a consistent way. - Marcus Aurelius', + 'It is never too late to be what you might have been. - George Eliot', 'It is not the man who has too little, but the man who craves more, that is poor. - Seneca', - 'I begin to speak only when I am certain what I will say is not better left unsaid - Cato the Younger', - 'Order your soul. Reduce your wants. - Augustine', - 'Be present above all else. - Naval Ravikant', + 'It is quality rather than quantity that matters. - Lucius Annaeus Seneca', + 'Knowing is not enough; we must apply. Being willing is not enough; we must do. - Leonardo da Vinci', 'Let all your things have their places; let each part of your business have its time. - Benjamin Franklin', - 'If you do not have a consistent goal in life, you can not live it in a consistent way. - Marcus Aurelius', + 'Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi', 'No surplus words or unnecessary actions. - Marcus Aurelius', + 'Nothing worth having comes easy. - Theodore Roosevelt', + 'Order your soul. Reduce your wants. - Augustine', 'People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius', + 'Simplicity is an acquired taste. - Katharine Gerould', + 'Simplicity is the consequence of refined emotions. - Jean D\'Alembert', + 'Simplicity is the essence of happiness. - Cedric Bledsoe', + 'Simplicity is the ultimate sophistication. - Leonardo da Vinci', + 'Smile, breathe, and go slowly. - Thich Nhat Hanh', + 'The only way to do great work is to love what you do. - Steve Jobs', + 'The whole future lies in uncertainty: live immediately. - Seneca', + 'Very little is needed to make a happy life. - Marcus Aurelius', + 'Waste no more time arguing what a good man should be, be one. - Marcus Aurelius', + 'Well begun is half done. - Aristotle', + 'When there is no desire, all things are at peace. - Laozi', + 'Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh', + 'Because you are alive, everything is possible. - Thich Nhat Hanh', + 'Breathing in, I calm body and mind. Breathing out, I smile. - Thich Nhat Hanh', + 'Life is available only in the present moment. - Thich Nhat Hanh', + 'The best way to take care of the future is to take care of the present moment. - Thich Nhat Hanh', + 'Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less. - Marie Curie', ])->random(); } } diff --git a/src/Illuminate/Foundation/Mix.php b/src/Illuminate/Foundation/Mix.php index 271d7dbd684351f02c659b5a9623f1e868c0f657..edd481787ee2e76741929527e637ecf8eccdf505 100644 --- a/src/Illuminate/Foundation/Mix.php +++ b/src/Illuminate/Foundation/Mix.php @@ -29,9 +29,15 @@ class Mix $manifestDirectory = "/{$manifestDirectory}"; } - if (file_exists(public_path($manifestDirectory.'/hot'))) { + if (is_file(public_path($manifestDirectory.'/hot'))) { $url = rtrim(file_get_contents(public_path($manifestDirectory.'/hot'))); + $customUrl = app('config')->get('app.mix_hot_proxy_url'); + + if (! empty($customUrl)) { + return new HtmlString("{$customUrl}{$path}"); + } + if (Str::startsWith($url, ['http://', 'https://'])) { return new HtmlString(Str::after($url, ':').$path); } @@ -42,7 +48,7 @@ class Mix $manifestPath = public_path($manifestDirectory.'/mix-manifest.json'); if (! isset($manifests[$manifestPath])) { - if (! file_exists($manifestPath)) { + if (! is_file($manifestPath)) { throw new Exception('The Mix manifest does not exist.'); } diff --git a/src/Illuminate/Foundation/PackageManifest.php b/src/Illuminate/Foundation/PackageManifest.php index df770aa2fba89c47e4d035f873cb3fbcfd10b384..202a8beb271d6e19d968cbb113de3de6eed6d661 100644 --- a/src/Illuminate/Foundation/PackageManifest.php +++ b/src/Illuminate/Foundation/PackageManifest.php @@ -102,11 +102,11 @@ class PackageManifest return $this->manifest; } - if (! file_exists($this->manifestPath)) { + if (! is_file($this->manifestPath)) { $this->build(); } - return $this->manifest = file_exists($this->manifestPath) ? + return $this->manifest = is_file($this->manifestPath) ? $this->files->getRequire($this->manifestPath) : []; } @@ -154,7 +154,7 @@ class PackageManifest */ protected function packagesToIgnore() { - if (! file_exists($this->basePath.'/composer.json')) { + if (! is_file($this->basePath.'/composer.json')) { return []; } @@ -173,8 +173,8 @@ class PackageManifest */ protected function write(array $manifest) { - if (! is_writable(dirname($this->manifestPath))) { - throw new Exception('The '.dirname($this->manifestPath).' directory must be present and writable.'); + if (! is_writable($dirname = dirname($this->manifestPath))) { + throw new Exception("The {$dirname} directory must be present and writable."); } $this->files->replace( diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 7c33b8d5da917fcbb6ab41d8b3075a9dd13a1ed0..e003ab12c5435bff5615f6cc22fd00e349968e7b 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -6,15 +6,24 @@ use Illuminate\Auth\Console\ClearResetsCommand; use Illuminate\Cache\Console\CacheTableCommand; use Illuminate\Cache\Console\ClearCommand as CacheClearCommand; use Illuminate\Cache\Console\ForgetCommand as CacheForgetCommand; +use Illuminate\Console\Scheduling\ScheduleClearCacheCommand; use Illuminate\Console\Scheduling\ScheduleFinishCommand; +use Illuminate\Console\Scheduling\ScheduleListCommand; use Illuminate\Console\Scheduling\ScheduleRunCommand; +use Illuminate\Console\Scheduling\ScheduleTestCommand; +use Illuminate\Console\Scheduling\ScheduleWorkCommand; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Database\Console\DbCommand; +use Illuminate\Database\Console\DumpCommand; use Illuminate\Database\Console\Factories\FactoryMakeCommand; +use Illuminate\Database\Console\PruneCommand; use Illuminate\Database\Console\Seeds\SeedCommand; use Illuminate\Database\Console\Seeds\SeederMakeCommand; use Illuminate\Database\Console\WipeCommand; +use Illuminate\Foundation\Console\CastMakeCommand; use Illuminate\Foundation\Console\ChannelMakeCommand; use Illuminate\Foundation\Console\ClearCompiledCommand; +use Illuminate\Foundation\Console\ComponentMakeCommand; use Illuminate\Foundation\Console\ConfigCacheCommand; use Illuminate\Foundation\Console\ConfigClearCommand; use Illuminate\Foundation\Console\ConsoleMakeCommand; @@ -37,7 +46,6 @@ use Illuminate\Foundation\Console\OptimizeClearCommand; use Illuminate\Foundation\Console\OptimizeCommand; use Illuminate\Foundation\Console\PackageDiscoverCommand; use Illuminate\Foundation\Console\PolicyMakeCommand; -use Illuminate\Foundation\Console\PresetCommand; use Illuminate\Foundation\Console\ProviderMakeCommand; use Illuminate\Foundation\Console\RequestMakeCommand; use Illuminate\Foundation\Console\ResourceMakeCommand; @@ -47,18 +55,25 @@ use Illuminate\Foundation\Console\RouteListCommand; use Illuminate\Foundation\Console\RuleMakeCommand; use Illuminate\Foundation\Console\ServeCommand; use Illuminate\Foundation\Console\StorageLinkCommand; +use Illuminate\Foundation\Console\StubPublishCommand; use Illuminate\Foundation\Console\TestMakeCommand; use Illuminate\Foundation\Console\UpCommand; use Illuminate\Foundation\Console\VendorPublishCommand; use Illuminate\Foundation\Console\ViewCacheCommand; use Illuminate\Foundation\Console\ViewClearCommand; use Illuminate\Notifications\Console\NotificationTableCommand; +use Illuminate\Queue\Console\BatchesTableCommand; +use Illuminate\Queue\Console\ClearCommand as QueueClearCommand; use Illuminate\Queue\Console\FailedTableCommand; use Illuminate\Queue\Console\FlushFailedCommand as FlushFailedQueueCommand; use Illuminate\Queue\Console\ForgetFailedCommand as ForgetFailedQueueCommand; use Illuminate\Queue\Console\ListenCommand as QueueListenCommand; use Illuminate\Queue\Console\ListFailedCommand as ListFailedQueueCommand; +use Illuminate\Queue\Console\MonitorCommand as QueueMonitorCommand; +use Illuminate\Queue\Console\PruneBatchesCommand as PruneBatchesQueueCommand; +use Illuminate\Queue\Console\PruneFailedJobsCommand; use Illuminate\Queue\Console\RestartCommand as QueueRestartCommand; +use Illuminate\Queue\Console\RetryBatchCommand as QueueRetryBatchCommand; use Illuminate\Queue\Console\RetryCommand as QueueRetryCommand; use Illuminate\Queue\Console\TableCommand; use Illuminate\Queue\Console\WorkCommand as QueueWorkCommand; @@ -81,6 +96,8 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ClearResets' => 'command.auth.resets.clear', 'ConfigCache' => 'command.config.cache', 'ConfigClear' => 'command.config.clear', + 'Db' => DbCommand::class, + 'DbPrune' => 'command.db.prune', 'DbWipe' => 'command.db.wipe', 'Down' => 'command.down', 'Environment' => 'command.environment', @@ -91,20 +108,29 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'Optimize' => 'command.optimize', 'OptimizeClear' => 'command.optimize.clear', 'PackageDiscover' => 'command.package.discover', - 'Preset' => 'command.preset', + 'QueueClear' => 'command.queue.clear', 'QueueFailed' => 'command.queue.failed', 'QueueFlush' => 'command.queue.flush', 'QueueForget' => 'command.queue.forget', 'QueueListen' => 'command.queue.listen', + 'QueueMonitor' => 'command.queue.monitor', + 'QueuePruneBatches' => 'command.queue.prune-batches', + 'QueuePruneFailedJobs' => 'command.queue.prune-failed-jobs', 'QueueRestart' => 'command.queue.restart', 'QueueRetry' => 'command.queue.retry', + 'QueueRetryBatch' => 'command.queue.retry-batch', 'QueueWork' => 'command.queue.work', 'RouteCache' => 'command.route.cache', 'RouteClear' => 'command.route.clear', 'RouteList' => 'command.route.list', + 'SchemaDump' => 'command.schema.dump', 'Seed' => 'command.seed', 'ScheduleFinish' => ScheduleFinishCommand::class, + 'ScheduleList' => ScheduleListCommand::class, 'ScheduleRun' => ScheduleRunCommand::class, + 'ScheduleClearCache' => ScheduleClearCacheCommand::class, + 'ScheduleTest' => ScheduleTestCommand::class, + 'ScheduleWork' => ScheduleWorkCommand::class, 'StorageLink' => 'command.storage.link', 'Up' => 'command.up', 'ViewCache' => 'command.view.cache', @@ -118,7 +144,9 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid */ protected $devCommands = [ 'CacheTable' => 'command.cache.table', + 'CastMake' => 'command.cast.make', 'ChannelMake' => 'command.channel.make', + 'ComponentMake' => 'command.component.make', 'ConsoleMake' => 'command.console.make', 'ControllerMake' => 'command.controller.make', 'EventGenerate' => 'command.event.generate', @@ -137,12 +165,14 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ProviderMake' => 'command.provider.make', 'QueueFailedTable' => 'command.queue.failed-table', 'QueueTable' => 'command.queue.table', + 'QueueBatchesTable' => 'command.queue.batches-table', 'RequestMake' => 'command.request.make', 'ResourceMake' => 'command.resource.make', 'RuleMake' => 'command.rule.make', 'SeederMake' => 'command.seeder.make', 'SessionTable' => 'command.session.table', 'Serve' => 'command.serve', + 'StubPublish' => 'command.stub.publish', 'TestMake' => 'command.test.make', 'VendorPublish' => 'command.vendor.publish', ]; @@ -210,6 +240,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerCastMakeCommand() + { + $this->app->singleton('command.cast.make', function ($app) { + return new CastMakeCommand($app['files']); + }); + } + /** * Register the command. * @@ -246,6 +288,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerComponentMakeCommand() + { + $this->app->singleton('command.component.make', function ($app) { + return new ComponentMakeCommand($app['files']); + }); + } + /** * Register the command. * @@ -294,6 +348,28 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerDbCommand() + { + $this->app->singleton(DbCommand::class); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerDbPruneCommand() + { + $this->app->singleton('command.db.prune', function ($app) { + return new PruneCommand($app['events']); + }); + } + /** * Register the command. * @@ -410,7 +486,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid protected function registerEventListCommand() { $this->app->singleton('command.event.list', function () { - return new EventListCommand(); + return new EventListCommand; }); } @@ -570,18 +646,6 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } - /** - * Register the command. - * - * @return void - */ - protected function registerPresetCommand() - { - $this->app->singleton('command.preset', function () { - return new PresetCommand; - }); - } - /** * Register the command. * @@ -642,6 +706,42 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerQueueMonitorCommand() + { + $this->app->singleton('command.queue.monitor', function ($app) { + return new QueueMonitorCommand($app['queue'], $app['events']); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerQueuePruneBatchesCommand() + { + $this->app->singleton('command.queue.prune-batches', function () { + return new PruneBatchesQueueCommand; + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerQueuePruneFailedJobsCommand() + { + $this->app->singleton('command.queue.prune-failed-jobs', function () { + return new PruneFailedJobsCommand; + }); + } + /** * Register the command. * @@ -666,6 +766,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerQueueRetryBatchCommand() + { + $this->app->singleton('command.queue.retry-batch', function () { + return new QueueRetryBatchCommand; + }); + } + /** * Register the command. * @@ -678,6 +790,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerQueueClearCommand() + { + $this->app->singleton('command.queue.clear', function () { + return new QueueClearCommand; + }); + } + /** * Register the command. * @@ -702,6 +826,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerQueueBatchesTableCommand() + { + $this->app->singleton('command.queue.batches-table', function ($app) { + return new BatchesTableCommand($app['files'], $app['composer']); + }); + } + /** * Register the command. * @@ -810,6 +946,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerSchemaDumpCommand() + { + $this->app->singleton('command.schema.dump', function () { + return new DumpCommand; + }); + } + /** * Register the command. * @@ -822,6 +970,16 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerScheduleClearCacheCommand() + { + $this->app->singleton(ScheduleClearCacheCommand::class); + } + /** * Register the command. * @@ -832,6 +990,16 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid $this->app->singleton(ScheduleFinishCommand::class); } + /** + * Register the command. + * + * @return void + */ + protected function registerScheduleListCommand() + { + $this->app->singleton(ScheduleListCommand::class); + } + /** * Register the command. * @@ -842,6 +1010,26 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid $this->app->singleton(ScheduleRunCommand::class); } + /** + * Register the command. + * + * @return void + */ + protected function registerScheduleTestCommand() + { + $this->app->singleton(ScheduleTestCommand::class); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerScheduleWorkCommand() + { + $this->app->singleton(ScheduleWorkCommand::class); + } + /** * Register the command. * @@ -854,6 +1042,18 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid }); } + /** + * Register the command. + * + * @return void + */ + protected function registerStubPublishCommand() + { + $this->app->singleton('command.stub.publish', function () { + return new StubPublishCommand; + }); + } + /** * Register the command. * diff --git a/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php b/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php index b23f18731a21532caaac77b946d6ecb65bd2fd95..f6131ca5e105aae590869c6650821080b6a1cf68 100644 --- a/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ConsoleSupportServiceProvider.php @@ -11,7 +11,7 @@ class ConsoleSupportServiceProvider extends AggregateServiceProvider implements /** * The provider class names. * - * @var array + * @var string[] */ protected $providers = [ ArtisanServiceProvider::class, diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index df563249ce74d4810703efb21a40690ff97f81d8..bb69c885045635a7ab74893dd103a9c9d687f41a 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -3,8 +3,11 @@ namespace Illuminate\Foundation\Providers; use Illuminate\Http\Request; +use Illuminate\Log\Events\MessageLogged; use Illuminate\Support\AggregateServiceProvider; use Illuminate\Support\Facades\URL; +use Illuminate\Testing\LoggedExceptionCollection; +use Illuminate\Testing\ParallelTestingServiceProvider; use Illuminate\Validation\ValidationException; class FoundationServiceProvider extends AggregateServiceProvider @@ -12,10 +15,11 @@ class FoundationServiceProvider extends AggregateServiceProvider /** * The provider class names. * - * @var array + * @var string[] */ protected $providers = [ FormRequestServiceProvider::class, + ParallelTestingServiceProvider::class, ]; /** @@ -43,12 +47,15 @@ class FoundationServiceProvider extends AggregateServiceProvider $this->registerRequestValidation(); $this->registerRequestSignatureValidation(); + $this->registerExceptionTracking(); } /** * Register the "validate" macro on the request. * * @return void + * + * @throws \Illuminate\Validation\ValidationException */ public function registerRequestValidation() { @@ -77,5 +84,33 @@ class FoundationServiceProvider extends AggregateServiceProvider Request::macro('hasValidSignature', function ($absolute = true) { return URL::hasValidSignature($this, $absolute); }); + + Request::macro('hasValidRelativeSignature', function () { + return URL::hasValidSignature($this, $absolute = false); + }); + } + + /** + * Register an event listener to track logged exceptions. + * + * @return void + */ + protected function registerExceptionTracking() + { + if (! $this->app->runningUnitTests()) { + return; + } + + $this->app->instance( + LoggedExceptionCollection::class, + new LoggedExceptionCollection + ); + + $this->app->make('events')->listen(MessageLogged::class, function ($event) { + if (isset($event->context['exception'])) { + $this->app->make(LoggedExceptionCollection::class) + ->push($event->context['exception']); + } + }); } } diff --git a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php index 11e63a8d044bda584b350b55538ec0ceaf54b6dc..70ea3086efe96a503c8b004e3af7c0b9716f72b6 100644 --- a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php +++ b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php @@ -27,19 +27,31 @@ class EventServiceProvider extends ServiceProvider * * @return void */ - public function boot() + public function register() { - $events = $this->getEvents(); + $this->booting(function () { + $events = $this->getEvents(); - foreach ($events as $event => $listeners) { - foreach (array_unique($listeners) as $listener) { - Event::listen($event, $listener); + foreach ($events as $event => $listeners) { + foreach (array_unique($listeners) as $listener) { + Event::listen($event, $listener); + } } - } - foreach ($this->subscribe as $subscriber) { - Event::subscribe($subscriber); - } + foreach ($this->subscribe as $subscriber) { + Event::subscribe($subscriber); + } + }); + } + + /** + * Boot any application services. + * + * @return void + */ + public function boot() + { + // } /** @@ -107,7 +119,7 @@ class EventServiceProvider extends ServiceProvider ->reduce(function ($discovered, $directory) { return array_merge_recursive( $discovered, - DiscoverEvents::within($directory, base_path()) + DiscoverEvents::within($directory, $this->eventDiscoveryBasePath()) ); }, []); } @@ -123,4 +135,14 @@ class EventServiceProvider extends ServiceProvider $this->app->path('Listeners'), ]; } + + /** + * Get the base path to be used during event discovery. + * + * @return string + */ + protected function eventDiscoveryBasePath() + { + return base_path(); + } } diff --git a/src/Illuminate/Foundation/Support/Providers/RouteServiceProvider.php b/src/Illuminate/Foundation/Support/Providers/RouteServiceProvider.php index b281da1a5b5d7a90fa705b7c04ca68f158f2978a..c8679e51ede6133c758dc98abc411b1993717a93 100644 --- a/src/Illuminate/Foundation/Support/Providers/RouteServiceProvider.php +++ b/src/Illuminate/Foundation/Support/Providers/RouteServiceProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Support\Providers; +use Closure; use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; @@ -21,6 +22,36 @@ class RouteServiceProvider extends ServiceProvider */ protected $namespace; + /** + * The callback that should be used to load the application's routes. + * + * @var \Closure|null + */ + protected $loadRoutesUsing; + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->booted(function () { + $this->setRootControllerNamespace(); + + if ($this->routesAreCached()) { + $this->loadCachedRoutes(); + } else { + $this->loadRoutes(); + + $this->app->booted(function () { + $this->app['router']->getRoutes()->refreshNameLookups(); + $this->app['router']->getRoutes()->refreshActionLookups(); + }); + } + }); + } + /** * Bootstrap any application services. * @@ -28,18 +59,20 @@ class RouteServiceProvider extends ServiceProvider */ public function boot() { - $this->setRootControllerNamespace(); + // + } - if ($this->routesAreCached()) { - $this->loadCachedRoutes(); - } else { - $this->loadRoutes(); + /** + * Register the callback that will be used to load the application's routes. + * + * @param \Closure $routesCallback + * @return $this + */ + protected function routes(Closure $routesCallback) + { + $this->loadRoutesUsing = $routesCallback; - $this->app->booted(function () { - $this->app['router']->getRoutes()->refreshNameLookups(); - $this->app['router']->getRoutes()->refreshActionLookups(); - }); - } + return $this; } /** @@ -83,7 +116,9 @@ class RouteServiceProvider extends ServiceProvider */ protected function loadRoutes() { - if (method_exists($this, 'map')) { + if (! is_null($this->loadRoutesUsing)) { + $this->app->call($this->loadRoutesUsing); + } elseif (method_exists($this, 'map')) { $this->app->call([$this, 'map']); } } diff --git a/src/Illuminate/Foundation/Testing/Assert.php b/src/Illuminate/Foundation/Testing/Assert.php deleted file mode 100644 index 8c655922089c33d4d0dafcf44188dc156a7066bc..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Testing/Assert.php +++ /dev/null @@ -1,148 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Testing; - -use ArrayAccess; -use Illuminate\Foundation\Testing\Constraints\ArraySubset; -use PHPUnit\Framework\Assert as PHPUnit; -use PHPUnit\Framework\Constraint\DirectoryExists; -use PHPUnit\Framework\Constraint\FileExists; -use PHPUnit\Framework\Constraint\LogicalNot; -use PHPUnit\Framework\Constraint\RegularExpression; -use PHPUnit\Framework\InvalidArgumentException; -use PHPUnit\Runner\Version; -use PHPUnit\Util\InvalidArgumentHelper; - -if (class_exists(Version::class) && (int) Version::series()[0] >= 8) { - /** - * @internal This class is not meant to be used or overwritten outside the framework itself. - */ - abstract class Assert extends PHPUnit - { - /** - * Asserts that an array has a specified subset. - * - * @param \ArrayAccess|array $subset - * @param \ArrayAccess|array $array - * @param bool $checkForIdentity - * @param string $msg - * @return void - */ - public static function assertArraySubset($subset, $array, bool $checkForIdentity = false, string $msg = ''): void - { - if (! (is_array($subset) || $subset instanceof ArrayAccess)) { - if (class_exists(InvalidArgumentException::class)) { - throw InvalidArgumentException::create(1, 'array or ArrayAccess'); - } else { - throw InvalidArgumentHelper::factory(1, 'array or ArrayAccess'); - } - } - - if (! (is_array($array) || $array instanceof ArrayAccess)) { - if (class_exists(InvalidArgumentException::class)) { - throw InvalidArgumentException::create(2, 'array or ArrayAccess'); - } else { - throw InvalidArgumentHelper::factory(2, 'array or ArrayAccess'); - } - } - - $constraint = new ArraySubset($subset, $checkForIdentity); - - PHPUnit::assertThat($array, $constraint, $msg); - } - - /** - * Asserts that a file does not exist. - * - * @param string $filename - * @param string $message - * @return void - */ - public static function assertFileDoesNotExist(string $filename, string $message = ''): void - { - static::assertThat($filename, new LogicalNot(new FileExists), $message); - } - - /** - * Asserts that a directory does not exist. - * - * @param string $directory - * @param string $message - * @return void - */ - public static function assertDirectoryDoesNotExist(string $directory, string $message = ''): void - { - static::assertThat($directory, new LogicalNot(new DirectoryExists), $message); - } - - /** - * Asserts that a string matches a given regular expression. - * - * @param string $pattern - * @param string $string - * @param string $message - * @return void - */ - public static function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void - { - static::assertThat($string, new RegularExpression($pattern), $message); - } - } -} else { - /** - * @internal This class is not meant to be used or overwritten outside the framework itself. - */ - abstract class Assert extends PHPUnit - { - /** - * Asserts that an array has a specified subset. - * - * @param \ArrayAccess|array $subset - * @param \ArrayAccess|array $array - * @param bool $checkForIdentity - * @param string $msg - * @return void - */ - public static function assertArraySubset($subset, $array, bool $checkForIdentity = false, string $msg = ''): void - { - PHPUnit::assertArraySubset($subset, $array, $checkForIdentity, $msg); - } - - /** - * Asserts that a file does not exist. - * - * @param string $filename - * @param string $message - * @return void - */ - public static function assertFileDoesNotExist(string $filename, string $message = ''): void - { - static::assertThat($filename, new LogicalNot(new FileExists), $message); - } - - /** - * Asserts that a directory does not exist. - * - * @param string $directory - * @param string $message - * @return void - */ - public static function assertDirectoryDoesNotExist(string $directory, string $message = ''): void - { - static::assertThat($directory, new LogicalNot(new DirectoryExists), $message); - } - - /** - * Asserts that a string matches a given regular expression. - * - * @param string $pattern - * @param string $string - * @param string $message - * @return void - */ - public static function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void - { - static::assertThat($string, new RegularExpression($pattern), $message); - } - } -} diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithAuthentication.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithAuthentication.php index 404a8bfb628d96e428d18d53929f10bebb91bfe7..9e8c0f5870b68b468e7632ebe45a618a5104d7e7 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithAuthentication.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithAuthentication.php @@ -10,30 +10,30 @@ trait InteractsWithAuthentication * Set the currently logged in user for the application. * * @param \Illuminate\Contracts\Auth\Authenticatable $user - * @param string|null $driver + * @param string|null $guard * @return $this */ - public function actingAs(UserContract $user, $driver = null) + public function actingAs(UserContract $user, $guard = null) { - return $this->be($user, $driver); + return $this->be($user, $guard); } /** * Set the currently logged in user for the application. * * @param \Illuminate\Contracts\Auth\Authenticatable $user - * @param string|null $driver + * @param string|null $guard * @return $this */ - public function be(UserContract $user, $driver = null) + public function be(UserContract $user, $guard = null) { if (isset($user->wasRecentlyCreated) && $user->wasRecentlyCreated) { $user->wasRecentlyCreated = false; } - $this->app['auth']->guard($driver)->setUser($user); + $this->app['auth']->guard($guard)->setUser($user); - $this->app['auth']->shouldUse($driver); + $this->app['auth']->shouldUse($guard); return $this; } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithConsole.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithConsole.php index 87e27c50da3b597c35b0073879292c71d6de95a0..38409d3d697ffbb431a80478e84ba9759d36fb7d 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithConsole.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithConsole.php @@ -4,8 +4,7 @@ namespace Illuminate\Foundation\Testing\Concerns; use Illuminate\Console\OutputStyle; use Illuminate\Contracts\Console\Kernel; -use Illuminate\Foundation\Testing\PendingCommand; -use Illuminate\Support\Arr; +use Illuminate\Testing\PendingCommand; trait InteractsWithConsole { @@ -23,6 +22,20 @@ trait InteractsWithConsole */ public $expectedOutput = []; + /** + * All of the output lines that aren't expected to be displayed. + * + * @var array + */ + public $unexpectedOutput = []; + + /** + * All of the expected output tables. + * + * @var array + */ + public $expectedTables = []; + /** * All of the expected questions. * @@ -30,12 +43,19 @@ trait InteractsWithConsole */ public $expectedQuestions = []; + /** + * All of the expected choice questions. + * + * @var array + */ + public $expectedChoices = []; + /** * Call artisan command and return code. * * @param string $command * @param array $parameters - * @return \Illuminate\Foundation\Testing\PendingCommand|int + * @return \Illuminate\Testing\PendingCommand|int */ public function artisan($command, $parameters = []) { @@ -43,16 +63,6 @@ trait InteractsWithConsole return $this->app[Kernel::class]->call($command, $parameters); } - $this->beforeApplicationDestroyed(function () { - if (count($this->expectedQuestions)) { - $this->fail('Question "'.Arr::first($this->expectedQuestions)[0].'" was not asked.'); - } - - if (count($this->expectedOutput)) { - $this->fail('Output "'.Arr::first($this->expectedOutput).'" was not printed.'); - } - }); - return new PendingCommand($this, $this->app, $command, $parameters); } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php index c84852e0040c15391d66959fee003d55acf2bb95..6949f6f8c5da8dec3f3c1952670645a973a81314 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php @@ -77,6 +77,19 @@ trait InteractsWithContainer return $this->instance($abstract, Mockery::spy(...array_filter(func_get_args()))); } + /** + * Instruct the container to forget a previously mocked / spied instance of an object. + * + * @param string $abstract + * @return $this + */ + protected function forgetMock($abstract) + { + $this->app->forgetInstance($abstract); + + return $this; + } + /** * Register an empty handler for Laravel Mix in the container. * diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php index 1da32f90af0e8d7b66754b3b29e47e562cf9f19b..8ccd7e2f397e0a79f7fe4c2b5e244337924cc3be 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php @@ -2,11 +2,15 @@ namespace Illuminate\Foundation\Testing\Concerns; +use Illuminate\Contracts\Support\Jsonable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Foundation\Testing\Constraints\HasInDatabase; -use Illuminate\Foundation\Testing\Constraints\SoftDeletedInDatabase; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; +use Illuminate\Testing\Constraints\CountInDatabase; +use Illuminate\Testing\Constraints\HasInDatabase; +use Illuminate\Testing\Constraints\NotSoftDeletedInDatabase; +use Illuminate\Testing\Constraints\SoftDeletedInDatabase; use PHPUnit\Framework\Constraint\LogicalNot as ReverseConstraint; trait InteractsWithDatabase @@ -14,7 +18,7 @@ trait InteractsWithDatabase /** * Assert that a given where condition exists in the database. * - * @param string $table + * @param \Illuminate\Database\Eloquent\Model|string $table * @param array $data * @param string|null $connection * @return $this @@ -22,7 +26,7 @@ trait InteractsWithDatabase protected function assertDatabaseHas($table, array $data, $connection = null) { $this->assertThat( - $table, new HasInDatabase($this->getConnection($connection), $data) + $this->getTable($table), new HasInDatabase($this->getConnection($connection), $data) ); return $this; @@ -31,7 +35,7 @@ trait InteractsWithDatabase /** * Assert that a given where condition does not exist in the database. * - * @param string $table + * @param \Illuminate\Database\Eloquent\Model|string $table * @param array $data * @param string|null $connection * @return $this @@ -42,7 +46,24 @@ trait InteractsWithDatabase new HasInDatabase($this->getConnection($connection), $data) ); - $this->assertThat($table, $constraint); + $this->assertThat($this->getTable($table), $constraint); + + return $this; + } + + /** + * Assert the count of table entries. + * + * @param \Illuminate\Database\Eloquent\Model|string $table + * @param int $count + * @param string|null $connection + * @return $this + */ + protected function assertDatabaseCount($table, int $count, $connection = null) + { + $this->assertThat( + $this->getTable($table), new CountInDatabase($this->getConnection($connection), $count) + ); return $this; } @@ -61,7 +82,7 @@ trait InteractsWithDatabase return $this->assertDatabaseMissing($table->getTable(), [$table->getKeyName() => $table->getKey()], $table->getConnectionName()); } - $this->assertDatabaseMissing($table, $data, $connection); + $this->assertDatabaseMissing($this->getTable($table), $data, $connection); return $this; } @@ -78,16 +99,78 @@ trait InteractsWithDatabase protected function assertSoftDeleted($table, array $data = [], $connection = null, $deletedAtColumn = 'deleted_at') { if ($this->isSoftDeletableModel($table)) { - return $this->assertSoftDeleted($table->getTable(), [$table->getKeyName() => $table->getKey()], $table->getConnectionName(), $table->getDeletedAtColumn()); + return $this->assertSoftDeleted( + $table->getTable(), + array_merge($data, [$table->getKeyName() => $table->getKey()]), + $table->getConnectionName(), + $table->getDeletedAtColumn() + ); + } + + $this->assertThat( + $this->getTable($table), new SoftDeletedInDatabase($this->getConnection($connection), $data, $deletedAtColumn) + ); + + return $this; + } + + /** + * Assert the given record has not been "soft deleted". + * + * @param \Illuminate\Database\Eloquent\Model|string $table + * @param array $data + * @param string|null $connection + * @param string|null $deletedAtColumn + * @return $this + */ + protected function assertNotSoftDeleted($table, array $data = [], $connection = null, $deletedAtColumn = 'deleted_at') + { + if ($this->isSoftDeletableModel($table)) { + return $this->assertNotSoftDeleted( + $table->getTable(), + array_merge($data, [$table->getKeyName() => $table->getKey()]), + $table->getConnectionName(), + $table->getDeletedAtColumn() + ); } $this->assertThat( - $table, new SoftDeletedInDatabase($this->getConnection($connection), $data, $deletedAtColumn) + $this->getTable($table), new NotSoftDeletedInDatabase($this->getConnection($connection), $data, $deletedAtColumn) ); return $this; } + /** + * Assert the given model exists in the database. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return $this + */ + protected function assertModelExists($model) + { + return $this->assertDatabaseHas( + $model->getTable(), + [$model->getKeyName() => $model->getKey()], + $model->getConnectionName() + ); + } + + /** + * Assert the given model does not exist in the database. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return $this + */ + protected function assertModelMissing($model) + { + return $this->assertDatabaseMissing( + $model->getTable(), + [$model->getKeyName() => $model->getKey()], + $model->getConnectionName() + ); + } + /** * Determine if the argument is a soft deletable model. * @@ -100,6 +183,25 @@ trait InteractsWithDatabase && in_array(SoftDeletes::class, class_uses_recursive($model)); } + /** + * Cast a JSON string to a database compatible type. + * + * @param array|string $value + * @return \Illuminate\Database\Query\Expression + */ + public function castAsJson($value) + { + if ($value instanceof Jsonable) { + $value = $value->toJson(); + } elseif (is_array($value) || is_object($value)) { + $value = json_encode($value); + } + + $value = DB::connection()->getPdo()->quote($value); + + return DB::raw("CAST($value AS JSON)"); + } + /** * Get the database connection. * @@ -115,13 +217,24 @@ trait InteractsWithDatabase return $database->connection($connection); } + /** + * Get the table name from the given model or string. + * + * @param \Illuminate\Database\Eloquent\Model|string $table + * @return string + */ + protected function getTable($table) + { + return is_subclass_of($table, Model::class) ? (new $table)->getTable() : $table; + } + /** * Seed a given database connection. * * @param array|string $class * @return $this */ - public function seed($class = 'DatabaseSeeder') + public function seed($class = 'Database\\Seeders\\DatabaseSeeder') { foreach (Arr::wrap($class) as $class) { $this->artisan('db:seed', ['--class' => $class, '--no-interaction' => true]); diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDeprecationHandling.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDeprecationHandling.php new file mode 100644 index 0000000000000000000000000000000000000000..7a914f7e01d2a9fd231c56233adee4c1cc812d62 --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDeprecationHandling.php @@ -0,0 +1,49 @@ +<?php + +namespace Illuminate\Foundation\Testing\Concerns; + +use ErrorException; + +trait InteractsWithDeprecationHandling +{ + /** + * The original deprecation handler. + * + * @var callable|null + */ + protected $originalDeprecationHandler; + + /** + * Restore deprecation handling. + * + * @return $this + */ + protected function withDeprecationHandling() + { + if ($this->originalDeprecationHandler) { + set_error_handler(tap($this->originalDeprecationHandler, function () { + $this->originalDeprecationHandler = null; + })); + } + + return $this; + } + + /** + * Disable deprecation handling for the test. + * + * @return $this + */ + protected function withoutDeprecationHandling() + { + if ($this->originalDeprecationHandler == null) { + $this->originalDeprecationHandler = set_error_handler(function ($level, $message, $file = '', $line = 0) { + if (in_array($level, [E_DEPRECATED, E_USER_DEPRECATED]) || (error_reporting() & $level)) { + throw new ErrorException($message, 0, $level, $file, $line); + } + }); + } + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php index bbd1c08c2fdffae570b0793b4fe23f35fab04cf4..5ce5686d6a93a928d255b157ffbb726777a540e9 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php @@ -2,11 +2,11 @@ namespace Illuminate\Foundation\Testing\Concerns; -use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Validation\ValidationException; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Throwable; trait InteractsWithExceptionHandling { @@ -64,7 +64,8 @@ trait InteractsWithExceptionHandling $this->originalExceptionHandler = app(ExceptionHandler::class); } - $this->app->instance(ExceptionHandler::class, new class($this->originalExceptionHandler, $except) implements ExceptionHandler { + $this->app->instance(ExceptionHandler::class, new class($this->originalExceptionHandler, $except) implements ExceptionHandler + { protected $except; protected $originalHandler; @@ -84,12 +85,12 @@ trait InteractsWithExceptionHandling /** * Report or log an exception. * - * @param \Exception $e + * @param \Throwable $e * @return void * * @throws \Exception */ - public function report(Exception $e) + public function report(Throwable $e) { // } @@ -97,10 +98,10 @@ trait InteractsWithExceptionHandling /** * Determine if the exception should be reported. * - * @param \Exception $e + * @param \Throwable $e * @return bool */ - public function shouldReport(Exception $e) + public function shouldReport(Throwable $e) { return false; } @@ -109,12 +110,12 @@ trait InteractsWithExceptionHandling * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request - * @param \Exception $e + * @param \Throwable $e * @return \Symfony\Component\HttpFoundation\Response * - * @throws \Exception + * @throws \Throwable */ - public function render($request, Exception $e) + public function render($request, Throwable $e) { foreach ($this->except as $class) { if ($e instanceof $class) { @@ -124,7 +125,7 @@ trait InteractsWithExceptionHandling if ($e instanceof NotFoundHttpException) { throw new NotFoundHttpException( - "{$request->method()} {$request->url()}", null, $e->getCode() + "{$request->method()} {$request->url()}", $e, $e->getCode() ); } @@ -135,12 +136,12 @@ trait InteractsWithExceptionHandling * Render an exception to the console. * * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param \Exception $e + * @param \Throwable $e * @return void */ - public function renderForConsole($output, Exception $e) + public function renderForConsole($output, Throwable $e) { - (new ConsoleApplication)->renderException($e, $output); + (new ConsoleApplication)->renderThrowable($e, $output); } }); diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php index a68995b05a9d0be5c9c767dbe2a177a0349b1a0c..5c8d9040e24487291914bfe8b5779207052fec68 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithRedis.php @@ -30,22 +30,18 @@ trait InteractsWithRedis */ public function setUpRedis() { - $app = $this->app ?? new Application; - $host = Env::get('REDIS_HOST', '127.0.0.1'); - $port = Env::get('REDIS_PORT', 6379); - if (! extension_loaded('redis')) { $this->markTestSkipped('The redis extension is not installed. Please install the extension to enable '.__CLASS__); - - return; } if (static::$connectionFailedOnceWithDefaultsSkip) { $this->markTestSkipped('Trying default host/port failed, please set environment variable REDIS_HOST & REDIS_PORT to enable '.__CLASS__); - - return; } + $app = $this->app ?? new Application; + $host = Env::get('REDIS_HOST', '127.0.0.1'); + $port = Env::get('REDIS_PORT', 6379); + foreach ($this->redisDriverProvider() as $driver) { $this->redis[$driver[0]] = new RedisManager($app, $driver[0], [ 'cluster' => false, @@ -57,6 +53,7 @@ trait InteractsWithRedis 'port' => $port, 'database' => 5, 'timeout' => 0.5, + 'name' => 'default', ], ]); } @@ -66,6 +63,7 @@ trait InteractsWithRedis } catch (Exception $e) { if ($host === '127.0.0.1' && $port === 6379 && Env::get('REDIS_HOST') === null) { static::$connectionFailedOnceWithDefaultsSkip = true; + $this->markTestSkipped('Trying default host/port failed, please set environment variable REDIS_HOST & REDIS_PORT to enable '.__CLASS__); } } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTime.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTime.php new file mode 100644 index 0000000000000000000000000000000000000000..184a2441ce86237e8293e392d6ffc2b3134d2201 --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTime.php @@ -0,0 +1,49 @@ +<?php + +namespace Illuminate\Foundation\Testing\Concerns; + +use DateTimeInterface; +use Illuminate\Foundation\Testing\Wormhole; +use Illuminate\Support\Carbon; + +trait InteractsWithTime +{ + /** + * Begin travelling to another time. + * + * @param int $value + * @return \Illuminate\Foundation\Testing\Wormhole + */ + public function travel($value) + { + return new Wormhole($value); + } + + /** + * Travel to another time. + * + * @param \DateTimeInterface $date + * @param callable|null $callback + * @return mixed + */ + public function travelTo(DateTimeInterface $date, $callback = null) + { + Carbon::setTestNow($date); + + if ($callback) { + return tap($callback(), function () { + Carbon::setTestNow(); + }); + } + } + + /** + * Travel back to the current time. + * + * @return \DateTimeInterface + */ + public function travelBack() + { + return Wormhole::back(); + } +} diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithViews.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithViews.php new file mode 100644 index 0000000000000000000000000000000000000000..b764abbf8243acaa650f503631f36eb9ad61c14b --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithViews.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Foundation\Testing\Concerns; + +use Illuminate\Support\Facades\View as ViewFacade; +use Illuminate\Support\MessageBag; +use Illuminate\Support\ViewErrorBag; +use Illuminate\Testing\TestComponent; +use Illuminate\Testing\TestView; +use Illuminate\View\View; + +trait InteractsWithViews +{ + /** + * Create a new TestView from the given view. + * + * @param string $view + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @return \Illuminate\Testing\TestView + */ + protected function view(string $view, array $data = []) + { + return new TestView(view($view, $data)); + } + + /** + * Render the contents of the given Blade template string. + * + * @param string $template + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @return \Illuminate\Testing\TestView + */ + protected function blade(string $template, array $data = []) + { + $tempDirectory = sys_get_temp_dir(); + + if (! in_array($tempDirectory, ViewFacade::getFinder()->getPaths())) { + ViewFacade::addLocation(sys_get_temp_dir()); + } + + $tempFileInfo = pathinfo(tempnam($tempDirectory, 'laravel-blade')); + + $tempFile = $tempFileInfo['dirname'].'/'.$tempFileInfo['filename'].'.blade.php'; + + file_put_contents($tempFile, $template); + + return new TestView(view($tempFileInfo['filename'], $data)); + } + + /** + * Render the given view component. + * + * @param string $componentClass + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @return \Illuminate\Testing\TestComponent + */ + protected function component(string $componentClass, array $data = []) + { + $component = $this->app->make($componentClass, $data); + + $view = value($component->resolveView(), $data); + + $view = $view instanceof View + ? $view->with($component->data()) + : view($view, $component->data()); + + return new TestComponent($component, $view); + } + + /** + * Populate the shared view error bag with the given errors. + * + * @param array $errors + * @param string $key + * @return $this + */ + protected function withViewErrors(array $errors, $key = 'default') + { + ViewFacade::share('errors', (new ViewErrorBag)->put($key, new MessageBag($errors))); + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php index edb679d7cdc1a983d6f3665c6dfe3a5ff5fc0a80..36e6734db9bf12031c28cb2833872c151e97fe61 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php +++ b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php @@ -4,9 +4,10 @@ namespace Illuminate\Foundation\Testing\Concerns; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Cookie\CookieValuePrefix; -use Illuminate\Foundation\Testing\TestResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Testing\LoggedExceptionCollection; +use Illuminate\Testing\TestResponse; use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; @@ -54,6 +55,15 @@ trait MakesHttpRequests */ protected $encryptCookies = true; + /** + * Indicated whether JSON requests should be performed "with credentials" (cookies). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials + * + * @var bool + */ + protected $withCredentials = false; + /** * Define additional headers to be sent with the request. * @@ -81,6 +91,18 @@ trait MakesHttpRequests return $this; } + /** + * Add an authorization token for the request. + * + * @param string $token + * @param string $type + * @return $this + */ + public function withToken(string $token, string $type = 'Bearer') + { + return $this->withHeader('Authorization', $type.' '.$token); + } + /** * Flush all the configured headers. * @@ -121,7 +143,8 @@ trait MakesHttpRequests } foreach ((array) $middleware as $abstract) { - $this->app->instance($abstract, new class { + $this->app->instance($abstract, new class + { public function handle($request, $next) { return $next($request); @@ -219,6 +242,18 @@ trait MakesHttpRequests return $this; } + /** + * Include cookies and authorization headers for JSON requests. + * + * @return $this + */ + public function withCredentials() + { + $this->withCredentials = true; + + return $this; + } + /** * Disable automatic encryption of cookie values. * @@ -249,7 +284,7 @@ trait MakesHttpRequests * * @param string $uri * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function get($uri, array $headers = []) { @@ -264,7 +299,7 @@ trait MakesHttpRequests * * @param string $uri * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function getJson($uri, array $headers = []) { @@ -277,7 +312,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function post($uri, array $data = [], array $headers = []) { @@ -293,7 +328,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function postJson($uri, array $data = [], array $headers = []) { @@ -306,7 +341,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function put($uri, array $data = [], array $headers = []) { @@ -322,7 +357,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function putJson($uri, array $data = [], array $headers = []) { @@ -335,7 +370,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function patch($uri, array $data = [], array $headers = []) { @@ -351,7 +386,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function patchJson($uri, array $data = [], array $headers = []) { @@ -364,7 +399,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function delete($uri, array $data = [], array $headers = []) { @@ -380,7 +415,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function deleteJson($uri, array $data = [], array $headers = []) { @@ -388,12 +423,12 @@ trait MakesHttpRequests } /** - * Visit the given URI with a OPTIONS request. + * Visit the given URI with an OPTIONS request. * * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function options($uri, array $data = [], array $headers = []) { @@ -404,12 +439,12 @@ trait MakesHttpRequests } /** - * Visit the given URI with a OPTIONS request, expecting a JSON response. + * Visit the given URI with an OPTIONS request, expecting a JSON response. * * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function optionsJson($uri, array $data = [], array $headers = []) { @@ -423,7 +458,7 @@ trait MakesHttpRequests * @param string $uri * @param array $data * @param array $headers - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function json($method, $uri, array $data = [], array $headers = []) { @@ -438,7 +473,13 @@ trait MakesHttpRequests ], $headers); return $this->call( - $method, $uri, [], [], $files, $this->transformHeadersToServerVars($headers), $content + $method, + $uri, + [], + $this->prepareCookiesForJsonRequest(), + $files, + $this->transformHeadersToServerVars($headers), + $content ); } @@ -452,7 +493,7 @@ trait MakesHttpRequests * @param array $files * @param array $server * @param string|null $content - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null) { @@ -469,12 +510,12 @@ trait MakesHttpRequests $request = Request::createFromBase($symfonyRequest) ); + $kernel->terminate($request, $response); + if ($this->followRedirects) { $response = $this->followRedirects($response); } - $kernel->terminate($request, $response); - return $this->createTestResponse($response); } @@ -566,20 +607,30 @@ trait MakesHttpRequests })->merge($this->unencryptedCookies)->all(); } + /** + * If enabled, add cookies for JSON requests. + * + * @return array + */ + protected function prepareCookiesForJsonRequest() + { + return $this->withCredentials ? $this->prepareCookiesForRequest() : []; + } + /** * Follow a redirect chain until a non-redirect is received. * * @param \Illuminate\Http\Response $response - * @return \Illuminate\Http\Response|\Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Http\Response|\Illuminate\Testing\TestResponse */ protected function followRedirects($response) { + $this->followRedirects = false; + while ($response->isRedirect()) { $response = $this->get($response->headers->get('Location')); } - $this->followRedirects = false; - return $response; } @@ -587,10 +638,16 @@ trait MakesHttpRequests * Create the test response instance from the given response. * * @param \Illuminate\Http\Response $response - * @return \Illuminate\Foundation\Testing\TestResponse + * @return \Illuminate\Testing\TestResponse */ protected function createTestResponse($response) { - return TestResponse::fromBaseResponse($response); + return tap(TestResponse::fromBaseResponse($response), function ($response) { + $response->withExceptions( + $this->app->bound(LoggedExceptionCollection::class) + ? $this->app->make(LoggedExceptionCollection::class) + : new LoggedExceptionCollection + ); + }); } } diff --git a/src/Illuminate/Foundation/Testing/Concerns/MocksApplicationServices.php b/src/Illuminate/Foundation/Testing/Concerns/MocksApplicationServices.php index 7fc360e76f75564894d57838c575a2df9ad8b7fc..66622950c766c1388030646bd855640336411c49 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/MocksApplicationServices.php +++ b/src/Illuminate/Foundation/Testing/Concerns/MocksApplicationServices.php @@ -8,6 +8,9 @@ use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Illuminate\Support\Facades\Event; use Mockery; +/** + * @deprecated Will be removed in a future Laravel version. + */ trait MocksApplicationServices { /** diff --git a/src/Illuminate/Foundation/Testing/DatabaseMigrations.php b/src/Illuminate/Foundation/Testing/DatabaseMigrations.php index 889a45328898e8ff15f44f2a202ab963ee67def7..10a3a7300af633f9561b82c3cbce48a8dd030837 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseMigrations.php +++ b/src/Illuminate/Foundation/Testing/DatabaseMigrations.php @@ -3,9 +3,12 @@ namespace Illuminate\Foundation\Testing; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\Traits\CanConfigureMigrationCommands; trait DatabaseMigrations { + use CanConfigureMigrationCommands; + /** * Define hooks to migrate the database before and after each test. * @@ -13,7 +16,7 @@ trait DatabaseMigrations */ public function runDatabaseMigrations() { - $this->artisan('migrate:fresh'); + $this->artisan('migrate:fresh', $this->migrateFreshUsing()); $this->app[Kernel::class]->setArtisan(null); diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php index 9870153bb33376755c5bbc39e1c80e457eb39077..e162e188a4ed5ee42f88d51e8f6780840f7a5082 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php @@ -14,14 +14,22 @@ trait DatabaseTransactions $database = $this->app->make('db'); foreach ($this->connectionsToTransact() as $name) { - $database->connection($name)->beginTransaction(); + $connection = $database->connection($name); + $dispatcher = $connection->getEventDispatcher(); + + $connection->unsetEventDispatcher(); + $connection->beginTransaction(); + $connection->setEventDispatcher($dispatcher); } $this->beforeApplicationDestroyed(function () use ($database) { foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); + $dispatcher = $connection->getEventDispatcher(); + $connection->unsetEventDispatcher(); $connection->rollBack(); + $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } }); diff --git a/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php b/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php new file mode 100644 index 0000000000000000000000000000000000000000..98204cceab487eafd11ffbdae9d5dbdd35846a5f --- /dev/null +++ b/src/Illuminate/Foundation/Testing/LazilyRefreshDatabase.php @@ -0,0 +1,34 @@ +<?php + +namespace Illuminate\Foundation\Testing; + +trait LazilyRefreshDatabase +{ + use RefreshDatabase { + refreshDatabase as baseRefreshDatabase; + } + + /** + * Define hooks to migrate the database before and after each test. + * + * @return void + */ + public function refreshDatabase() + { + $database = $this->app->make('db'); + + $database->beforeExecuting(function () { + if (RefreshDatabaseState::$lazilyRefreshed) { + return; + } + + RefreshDatabaseState::$lazilyRefreshed = true; + + $this->baseRefreshDatabase(); + }); + + $this->beforeApplicationDestroyed(function () { + RefreshDatabaseState::$lazilyRefreshed = false; + }); + } +} diff --git a/src/Illuminate/Foundation/Testing/PendingCommand.php b/src/Illuminate/Foundation/Testing/PendingCommand.php deleted file mode 100644 index 79f9ce4fb718e2785213a15e2575b6aac0710f37..0000000000000000000000000000000000000000 --- a/src/Illuminate/Foundation/Testing/PendingCommand.php +++ /dev/null @@ -1,225 +0,0 @@ -<?php - -namespace Illuminate\Foundation\Testing; - -use Illuminate\Console\OutputStyle; -use Illuminate\Contracts\Console\Kernel; -use Mockery; -use Mockery\Exception\NoMatchingExpectationException; -use PHPUnit\Framework\TestCase as PHPUnitTestCase; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Output\Output; - -class PendingCommand -{ - /** - * The test being run. - * - * @var \Illuminate\Foundation\Testing\TestCase - */ - public $test; - - /** - * The application instance. - * - * @var \Illuminate\Contracts\Foundation\Application - */ - protected $app; - - /** - * The command to run. - * - * @var string - */ - protected $command; - - /** - * The parameters to pass to the command. - * - * @var array - */ - protected $parameters; - - /** - * The expected exit code. - * - * @var int - */ - protected $expectedExitCode; - - /** - * Determine if command has executed. - * - * @var bool - */ - protected $hasExecuted = false; - - /** - * Create a new pending console command run. - * - * @param \PHPUnit\Framework\TestCase $test - * @param \Illuminate\Contracts\Foundation\Application $app - * @param string $command - * @param array $parameters - * @return void - */ - public function __construct(PHPUnitTestCase $test, $app, $command, $parameters) - { - $this->app = $app; - $this->test = $test; - $this->command = $command; - $this->parameters = $parameters; - } - - /** - * Specify a question that should be asked when the command runs. - * - * @param string $question - * @param string $answer - * @return $this - */ - public function expectsQuestion($question, $answer) - { - $this->test->expectedQuestions[] = [$question, $answer]; - - return $this; - } - - /** - * Specify output that should be printed when the command runs. - * - * @param string $output - * @return $this - */ - public function expectsOutput($output) - { - $this->test->expectedOutput[] = $output; - - return $this; - } - - /** - * Assert that the command has the given exit code. - * - * @param int $exitCode - * @return $this - */ - public function assertExitCode($exitCode) - { - $this->expectedExitCode = $exitCode; - - return $this; - } - - /** - * Execute the command. - * - * @return int - */ - public function execute() - { - return $this->run(); - } - - /** - * Execute the command. - * - * @return int - */ - public function run() - { - $this->hasExecuted = true; - - $mock = $this->mockConsoleOutput(); - - try { - $exitCode = $this->app[Kernel::class]->call($this->command, $this->parameters, $mock); - } catch (NoMatchingExpectationException $e) { - if ($e->getMethodName() === 'askQuestion') { - $this->test->fail('Unexpected question "'.$e->getActualArguments()[0]->getQuestion().'" was asked.'); - } - - throw $e; - } - - if ($this->expectedExitCode !== null) { - $this->test->assertEquals( - $this->expectedExitCode, $exitCode, - "Expected status code {$this->expectedExitCode} but received {$exitCode}." - ); - } - - return $exitCode; - } - - /** - * Mock the application's console output. - * - * @return \Mockery\MockInterface - */ - protected function mockConsoleOutput() - { - $mock = Mockery::mock(OutputStyle::class.'[askQuestion]', [ - (new ArrayInput($this->parameters)), $this->createABufferedOutputMock(), - ]); - - foreach ($this->test->expectedQuestions as $i => $question) { - $mock->shouldReceive('askQuestion') - ->once() - ->ordered() - ->with(Mockery::on(function ($argument) use ($question) { - return $argument->getQuestion() == $question[0]; - })) - ->andReturnUsing(function () use ($question, $i) { - unset($this->test->expectedQuestions[$i]); - - return $question[1]; - }); - } - - $this->app->bind(OutputStyle::class, function () use ($mock) { - return $mock; - }); - - return $mock; - } - - /** - * Create a mock for the buffered output. - * - * @return \Mockery\MockInterface - */ - private function createABufferedOutputMock() - { - $mock = Mockery::mock(BufferedOutput::class.'[doWrite]') - ->shouldAllowMockingProtectedMethods() - ->shouldIgnoreMissing(); - - foreach ($this->test->expectedOutput as $i => $output) { - $mock->shouldReceive('doWrite') - ->once() - ->ordered() - ->with($output, Mockery::any()) - ->andReturnUsing(function () use ($i) { - unset($this->test->expectedOutput[$i]); - }); - } - - return $mock; - } - - /** - * Handle the object's destruction. - * - * @return void - */ - public function __destruct() - { - if ($this->hasExecuted) { - return; - } - - $this->run(); - } -} diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabase.php b/src/Illuminate/Foundation/Testing/RefreshDatabase.php index e968ad90c9ac4977f3bede35fd7bbacd1bfdb69f..48390039b5ecc29610dc746c8e3b5d08dd42138e 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabase.php @@ -3,9 +3,12 @@ namespace Illuminate\Foundation\Testing; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\Traits\CanConfigureMigrationCommands; trait RefreshDatabase { + use CanConfigureMigrationCommands; + /** * Define hooks to migrate the database before and after each test. * @@ -16,6 +19,8 @@ trait RefreshDatabase $this->usingInMemoryDatabase() ? $this->refreshInMemoryDatabase() : $this->refreshTestDatabase(); + + $this->afterRefreshingDatabase(); } /** @@ -37,11 +42,24 @@ trait RefreshDatabase */ protected function refreshInMemoryDatabase() { - $this->artisan('migrate'); + $this->artisan('migrate', $this->migrateUsing()); $this->app[Kernel::class]->setArtisan(null); } + /** + * The parameters that should be used when running "migrate". + * + * @return array + */ + protected function migrateUsing() + { + return [ + '--seed' => $this->shouldSeed(), + '--seeder' => $this->seeder(), + ]; + } + /** * Refresh a conventional test database. * @@ -50,10 +68,7 @@ trait RefreshDatabase protected function refreshTestDatabase() { if (! RefreshDatabaseState::$migrated) { - $this->artisan('migrate:fresh', [ - '--drop-views' => $this->shouldDropViews(), - '--drop-types' => $this->shouldDropTypes(), - ]); + $this->artisan('migrate:fresh', $this->migrateFreshUsing()); $this->app[Kernel::class]->setArtisan(null); @@ -87,7 +102,7 @@ trait RefreshDatabase $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); - $connection->rollback(); + $connection->rollBack(); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } @@ -106,24 +121,12 @@ trait RefreshDatabase } /** - * Determine if views should be dropped when refreshing the database. + * Perform any work that should take place once the database has finished refreshing. * - * @return bool - */ - protected function shouldDropViews() - { - return property_exists($this, 'dropViews') - ? $this->dropViews : false; - } - - /** - * Determine if types should be dropped when refreshing the database. - * - * @return bool + * @return void */ - protected function shouldDropTypes() + protected function afterRefreshingDatabase() { - return property_exists($this, 'dropTypes') - ? $this->dropTypes : false; + // ... } } diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabaseState.php b/src/Illuminate/Foundation/Testing/RefreshDatabaseState.php index 1f33087396f60a1511b3d2c6b28ccc1a9e2c7530..a42d3d081bda940a3b96b2f5f6fe5655cb3537de 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabaseState.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabaseState.php @@ -10,4 +10,11 @@ class RefreshDatabaseState * @var bool */ public static $migrated = false; + + /** + * Indicates if a lazy refresh hook has been invoked. + * + * @var bool + */ + public static $lazilyRefreshed = false; } diff --git a/src/Illuminate/Foundation/Testing/TestCase.php b/src/Illuminate/Foundation/Testing/TestCase.php index 2ac907f7edf4122db0ed9b3a79e127bb6e31181c..b18d0adb410b10e3cbc4f4618e7b23b0f4ab0da0 100644 --- a/src/Illuminate/Foundation/Testing/TestCase.php +++ b/src/Illuminate/Foundation/Testing/TestCase.php @@ -6,7 +6,9 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Console\Application as Artisan; use Illuminate\Database\Eloquent\Model; +use Illuminate\Queue\Queue; use Illuminate\Support\Facades\Facade; +use Illuminate\Support\Facades\ParallelTesting; use Illuminate\Support\Str; use Mockery; use Mockery\Exception\InvalidCountException; @@ -20,14 +22,17 @@ abstract class TestCase extends BaseTestCase Concerns\InteractsWithAuthentication, Concerns\InteractsWithConsole, Concerns\InteractsWithDatabase, + Concerns\InteractsWithDeprecationHandling, Concerns\InteractsWithExceptionHandling, Concerns\InteractsWithSession, + Concerns\InteractsWithTime, + Concerns\InteractsWithViews, Concerns\MocksApplicationServices; /** * The Illuminate application instance. * - * @var \Illuminate\Contracts\Foundation\Application + * @var \Illuminate\Foundation\Application */ protected $app; @@ -79,6 +84,8 @@ abstract class TestCase extends BaseTestCase if (! $this->app) { $this->refreshApplication(); + + ParallelTesting::callSetUpTestCaseCallbacks($this); } $this->setUpTraits(); @@ -142,12 +149,16 @@ abstract class TestCase extends BaseTestCase * Clean up the testing environment before the next test. * * @return void + * + * @throws \Mockery\Exception\InvalidCountException */ protected function tearDown(): void { if ($this->app) { $this->callBeforeApplicationDestroyedCallbacks(); + ParallelTesting::callTearDownTestCaseCallbacks($this); + $this->app->flush(); $this->app = null; @@ -190,6 +201,8 @@ abstract class TestCase extends BaseTestCase Artisan::forgetBootstrappers(); + Queue::createPayloadUsing(null); + if ($this->callbackException) { throw $this->callbackException; } diff --git a/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php b/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php new file mode 100644 index 0000000000000000000000000000000000000000..aafca6f1f249eb9e9551c68e344b9852747b1d11 --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Traits/CanConfigureMigrationCommands.php @@ -0,0 +1,64 @@ +<?php + +namespace Illuminate\Foundation\Testing\Traits; + +trait CanConfigureMigrationCommands +{ + /** + * The parameters that should be used when running "migrate:fresh". + * + * @return array + */ + protected function migrateFreshUsing() + { + $seeder = $this->seeder(); + + return array_merge( + [ + '--drop-views' => $this->shouldDropViews(), + '--drop-types' => $this->shouldDropTypes(), + ], + $seeder ? ['--seeder' => $seeder] : ['--seed' => $this->shouldSeed()] + ); + } + + /** + * Determine if views should be dropped when refreshing the database. + * + * @return bool + */ + protected function shouldDropViews() + { + return property_exists($this, 'dropViews') ? $this->dropViews : false; + } + + /** + * Determine if types should be dropped when refreshing the database. + * + * @return bool + */ + protected function shouldDropTypes() + { + return property_exists($this, 'dropTypes') ? $this->dropTypes : false; + } + + /** + * Determine if the seed task should be run when refreshing the database. + * + * @return bool + */ + protected function shouldSeed() + { + return property_exists($this, 'seed') ? $this->seed : false; + } + + /** + * Determine the specific seeder class that should be used when refreshing the database. + * + * @return mixed + */ + protected function seeder() + { + return property_exists($this, 'seeder') ? $this->seeder : false; + } +} diff --git a/src/Illuminate/Foundation/Testing/Wormhole.php b/src/Illuminate/Foundation/Testing/Wormhole.php new file mode 100644 index 0000000000000000000000000000000000000000..54fe0fa0bb4c596887e5c98a39c5135e73bcd5fb --- /dev/null +++ b/src/Illuminate/Foundation/Testing/Wormhole.php @@ -0,0 +1,245 @@ +<?php + +namespace Illuminate\Foundation\Testing; + +use Illuminate\Support\Carbon; + +class Wormhole +{ + /** + * The amount of time to travel. + * + * @var int + */ + public $value; + + /** + * Create a new wormhole instance. + * + * @param int $value + * @return void + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * Travel forward the given number of milliseconds. + * + * @param callable|null $callback + * @return mixed + */ + public function millisecond($callback = null) + { + return $this->milliseconds($callback); + } + + /** + * Travel forward the given number of milliseconds. + * + * @param callable|null $callback + * @return mixed + */ + public function milliseconds($callback = null) + { + Carbon::setTestNow(Carbon::now()->addMilliseconds($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of seconds. + * + * @param callable|null $callback + * @return mixed + */ + public function second($callback = null) + { + return $this->seconds($callback); + } + + /** + * Travel forward the given number of seconds. + * + * @param callable|null $callback + * @return mixed + */ + public function seconds($callback = null) + { + Carbon::setTestNow(Carbon::now()->addSeconds($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of minutes. + * + * @param callable|null $callback + * @return mixed + */ + public function minute($callback = null) + { + return $this->minutes($callback); + } + + /** + * Travel forward the given number of minutes. + * + * @param callable|null $callback + * @return mixed + */ + public function minutes($callback = null) + { + Carbon::setTestNow(Carbon::now()->addMinutes($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of hours. + * + * @param callable|null $callback + * @return mixed + */ + public function hour($callback = null) + { + return $this->hours($callback); + } + + /** + * Travel forward the given number of hours. + * + * @param callable|null $callback + * @return mixed + */ + public function hours($callback = null) + { + Carbon::setTestNow(Carbon::now()->addHours($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of days. + * + * @param callable|null $callback + * @return mixed + */ + public function day($callback = null) + { + return $this->days($callback); + } + + /** + * Travel forward the given number of days. + * + * @param callable|null $callback + * @return mixed + */ + public function days($callback = null) + { + Carbon::setTestNow(Carbon::now()->addDays($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of weeks. + * + * @param callable|null $callback + * @return mixed + */ + public function week($callback = null) + { + return $this->weeks($callback); + } + + /** + * Travel forward the given number of weeks. + * + * @param callable|null $callback + * @return mixed + */ + public function weeks($callback = null) + { + Carbon::setTestNow(Carbon::now()->addWeeks($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of months. + * + * @param callable|null $callback + * @return mixed + */ + public function month($callback = null) + { + return $this->months($callback); + } + + /** + * Travel forward the given number of months. + * + * @param callable|null $callback + * @return mixed + */ + public function months($callback = null) + { + Carbon::setTestNow(Carbon::now()->addMonths($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel forward the given number of years. + * + * @param callable|null $callback + * @return mixed + */ + public function year($callback = null) + { + return $this->years($callback); + } + + /** + * Travel forward the given number of years. + * + * @param callable|null $callback + * @return mixed + */ + public function years($callback = null) + { + Carbon::setTestNow(Carbon::now()->addYears($this->value)); + + return $this->handleCallback($callback); + } + + /** + * Travel back to the current time. + * + * @return \DateTimeInterface + */ + public static function back() + { + Carbon::setTestNow(); + + return Carbon::now(); + } + + /** + * Handle the given optional execution callback. + * + * @param callable|null $callback + * @return mixed + */ + protected function handleCallback($callback) + { + if ($callback) { + return tap($callback(), function () { + Carbon::setTestNow(); + }); + } + } +} diff --git a/src/Illuminate/Foundation/helpers.php b/src/Illuminate/Foundation/helpers.php index 4d161219f2908131e43bcfb97b9bf51ee9a46451..bd879a8f6d6130210e1a43ec947dbb60ec7e2551 100644 --- a/src/Illuminate/Foundation/helpers.php +++ b/src/Illuminate/Foundation/helpers.php @@ -12,14 +12,13 @@ use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\Validation\Factory as ValidationFactory; use Illuminate\Contracts\View\Factory as ViewFactory; -use Illuminate\Database\Eloquent\Factory as EloquentFactory; +use Illuminate\Foundation\Bus\PendingClosureDispatch; use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Foundation\Mix; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Queue\CallQueuedClosure; use Illuminate\Support\Facades\Date; use Illuminate\Support\HtmlString; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Symfony\Component\HttpFoundation\Response; if (! function_exists('abort')) { @@ -29,7 +28,7 @@ if (! function_exists('abort')) { * @param \Symfony\Component\HttpFoundation\Response|\Illuminate\Contracts\Support\Responsable|int $code * @param string $message * @param array $headers - * @return void + * @return never * * @throws \Symfony\Component\HttpKernel\Exception\HttpException * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException @@ -51,7 +50,7 @@ if (! function_exists('abort_if')) { * Throw an HttpException with the given data if the given condition is true. * * @param bool $boolean - * @param int $code + * @param \Symfony\Component\HttpFoundation\Response|\Illuminate\Contracts\Support\Responsable|int $code * @param string $message * @param array $headers * @return void @@ -72,7 +71,7 @@ if (! function_exists('abort_unless')) { * Throw an HttpException with the given data unless the given condition is true. * * @param bool $boolean - * @param int $code + * @param \Symfony\Component\HttpFoundation\Response|\Illuminate\Contracts\Support\Responsable|int $code * @param string $message * @param array $headers * @return void @@ -385,67 +384,41 @@ if (! function_exists('dispatch')) { */ function dispatch($job) { - if ($job instanceof Closure) { - $job = CallQueuedClosure::create($job); - } - - return new PendingDispatch($job); + return $job instanceof Closure + ? new PendingClosureDispatch(CallQueuedClosure::create($job)) + : new PendingDispatch($job); } } -if (! function_exists('dispatch_now')) { +if (! function_exists('dispatch_sync')) { /** * Dispatch a command to its appropriate handler in the current process. * + * Queueable jobs will be dispatched to the "sync" queue. + * * @param mixed $job * @param mixed $handler * @return mixed */ - function dispatch_now($job, $handler = null) + function dispatch_sync($job, $handler = null) { - return app(Dispatcher::class)->dispatchNow($job, $handler); + return app(Dispatcher::class)->dispatchSync($job, $handler); } } -if (! function_exists('elixir')) { +if (! function_exists('dispatch_now')) { /** - * Get the path to a versioned Elixir file. - * - * @param string $file - * @param string $buildDirectory - * @return string + * Dispatch a command to its appropriate handler in the current process. * - * @throws \InvalidArgumentException + * @param mixed $job + * @param mixed $handler + * @return mixed * - * @deprecated Use Laravel Mix instead. + * @deprecated Will be removed in a future Laravel version. */ - function elixir($file, $buildDirectory = 'build') + function dispatch_now($job, $handler = null) { - static $manifest = []; - static $manifestPath; - - if (empty($manifest) || $manifestPath !== $buildDirectory) { - $path = public_path($buildDirectory.'/rev-manifest.json'); - - if (file_exists($path)) { - $manifest = json_decode(file_get_contents($path), true); - $manifestPath = $buildDirectory; - } - } - - $file = ltrim($file, '/'); - - if (isset($manifest[$file])) { - return '/'.trim($buildDirectory.'/'.$manifest[$file], '/'); - } - - $unversioned = public_path($file); - - if (file_exists($unversioned)) { - return '/'.trim($file, '/'); - } - - throw new InvalidArgumentException("File {$file} not defined in asset manifest."); + return app(Dispatcher::class)->dispatchNow($job, $handler); } } @@ -478,29 +451,6 @@ if (! function_exists('event')) { } } -if (! function_exists('factory')) { - /** - * Create a model factory builder for a given class, name, and amount. - * - * @param dynamic class|class,name|class,amount|class,name,amount - * @return \Illuminate\Database\Eloquent\FactoryBuilder - */ - function factory() - { - $factory = app(EloquentFactory::class); - - $arguments = func_get_args(); - - if (isset($arguments[1]) && is_string($arguments[1])) { - return $factory->of($arguments[0], $arguments[1])->times($arguments[2] ?? null); - } elseif (isset($arguments[1])) { - return $factory->of($arguments[0])->times($arguments[1]); - } - - return $factory->of($arguments[0]); - } -} - if (! function_exists('info')) { /** * Write some information to the log. @@ -533,6 +483,19 @@ if (! function_exists('logger')) { } } +if (! function_exists('lang_path')) { + /** + * Get the path to the language folder. + * + * @param string $path + * @return string + */ + function lang_path($path = '') + { + return app('path.lang').($path ? DIRECTORY_SEPARATOR.$path : $path); + } +} + if (! function_exists('logs')) { /** * Get a log driver instance. @@ -654,14 +617,13 @@ if (! function_exists('report')) { /** * Report an exception. * - * @param \Throwable $exception + * @param \Throwable|string $exception * @return void */ function report($exception) { - if ($exception instanceof Throwable && - ! $exception instanceof Exception) { - $exception = new FatalThrowableError($exception); + if (is_string($exception)) { + $exception = new Exception($exception); } app(ExceptionHandler::class)->report($exception); @@ -674,7 +636,7 @@ if (! function_exists('request')) { * * @param array|string|null $key * @param mixed $default - * @return \Illuminate\Http\Request|string|array + * @return \Illuminate\Http\Request|string|array|null */ function request($key = null, $default = null) { @@ -710,7 +672,7 @@ if (! function_exists('rescue')) { report($e); } - return $rescue instanceof Closure ? $rescue($e) : $rescue; + return value($rescue, $e); } } } @@ -746,7 +708,7 @@ if (! function_exists('response')) { /** * Return a new response from the application. * - * @param \Illuminate\View\View|string|array|null $content + * @param \Illuminate\Contracts\View\View|string|array|null $content * @param int $status * @param array $headers * @return \Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory @@ -957,7 +919,7 @@ if (! function_exists('view')) { * @param string|null $view * @param \Illuminate\Contracts\Support\Arrayable|array $data * @param array $mergeData - * @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory + * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ function view($view = null, $data = [], $mergeData = []) { diff --git a/src/Illuminate/Hashing/ArgonHasher.php b/src/Illuminate/Hashing/ArgonHasher.php index 41109c9b07990dc85aa26ae80b04157acdfd0f18..b999257f4b523af00bc5fd7c48e116294abf69bb 100644 --- a/src/Illuminate/Hashing/ArgonHasher.php +++ b/src/Illuminate/Hashing/ArgonHasher.php @@ -45,7 +45,7 @@ class ArgonHasher extends AbstractHasher implements HasherContract { $this->time = $options['time'] ?? $this->time; $this->memory = $options['memory'] ?? $this->memory; - $this->threads = $options['threads'] ?? $this->threads; + $this->threads = $this->threads($options); $this->verifyAlgorithm = $options['verify'] ?? $this->verifyAlgorithm; } @@ -180,13 +180,17 @@ class ArgonHasher extends AbstractHasher implements HasherContract } /** - * Extract the threads value from the options array. + * Extract the thread's value from the options array. * * @param array $options * @return int */ protected function threads(array $options) { + if (defined('PASSWORD_ARGON2_PROVIDER') && PASSWORD_ARGON2_PROVIDER === 'sodium') { + return 1; + } + return $options['threads'] ?? $this->threads; } } diff --git a/src/Illuminate/Hashing/composer.json b/src/Illuminate/Hashing/composer.json index 80d47dde6a0321c569e70e344a2f11641a4a3f27..6ad3411c7cfc431bf7937bf1a5a01284da34b55b 100755 --- a/src/Illuminate/Hashing/composer.json +++ b/src/Illuminate/Hashing/composer.json @@ -14,9 +14,9 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/contracts": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -25,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Http/Client/ConnectionException.php b/src/Illuminate/Http/Client/ConnectionException.php new file mode 100644 index 0000000000000000000000000000000000000000..eac85dc71d27499a48e0f9a7c9fbfd6c7e4207a2 --- /dev/null +++ b/src/Illuminate/Http/Client/ConnectionException.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Http\Client; + +class ConnectionException extends HttpClientException +{ + // +} diff --git a/src/Illuminate/Http/Client/Events/ConnectionFailed.php b/src/Illuminate/Http/Client/Events/ConnectionFailed.php new file mode 100644 index 0000000000000000000000000000000000000000..504006c80539d1ca3eec118d24edd22bbb3cf805 --- /dev/null +++ b/src/Illuminate/Http/Client/Events/ConnectionFailed.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Http\Client\Events; + +use Illuminate\Http\Client\Request; + +class ConnectionFailed +{ + /** + * The request instance. + * + * @var \Illuminate\Http\Client\Request + */ + public $request; + + /** + * Create a new event instance. + * + * @param \Illuminate\Http\Client\Request $request + * @return void + */ + public function __construct(Request $request) + { + $this->request = $request; + } +} diff --git a/src/Illuminate/Http/Client/Events/RequestSending.php b/src/Illuminate/Http/Client/Events/RequestSending.php new file mode 100644 index 0000000000000000000000000000000000000000..1b363fb751b30c7f6fffef3491044a1e9cf66774 --- /dev/null +++ b/src/Illuminate/Http/Client/Events/RequestSending.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Http\Client\Events; + +use Illuminate\Http\Client\Request; + +class RequestSending +{ + /** + * The request instance. + * + * @var \Illuminate\Http\Client\Request + */ + public $request; + + /** + * Create a new event instance. + * + * @param \Illuminate\Http\Client\Request $request + * @return void + */ + public function __construct(Request $request) + { + $this->request = $request; + } +} diff --git a/src/Illuminate/Http/Client/Events/ResponseReceived.php b/src/Illuminate/Http/Client/Events/ResponseReceived.php new file mode 100644 index 0000000000000000000000000000000000000000..77be7aba76620ac38fb4b6aa2414feffc802e2bf --- /dev/null +++ b/src/Illuminate/Http/Client/Events/ResponseReceived.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Http\Client\Events; + +use Illuminate\Http\Client\Request; +use Illuminate\Http\Client\Response; + +class ResponseReceived +{ + /** + * The request instance. + * + * @var \Illuminate\Http\Client\Request + */ + public $request; + + /** + * The response instance. + * + * @var \Illuminate\Http\Client\Response + */ + public $response; + + /** + * Create a new event instance. + * + * @param \Illuminate\Http\Client\Request $request + * @param \Illuminate\Http\Client\Response $response + * @return void + */ + public function __construct(Request $request, Response $response) + { + $this->request = $request; + $this->response = $response; + } +} diff --git a/src/Illuminate/Http/Client/Factory.php b/src/Illuminate/Http/Client/Factory.php new file mode 100644 index 0000000000000000000000000000000000000000..131e669a4eab623e8078a0c2f0ac7c62ef483d10 --- /dev/null +++ b/src/Illuminate/Http/Client/Factory.php @@ -0,0 +1,394 @@ +<?php + +namespace Illuminate\Http\Client; + +use Closure; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response as Psr7Response; +use GuzzleHttp\TransferStats; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\Macroable; +use PHPUnit\Framework\Assert as PHPUnit; + +/** + * @method \Illuminate\Http\Client\PendingRequest accept(string $contentType) + * @method \Illuminate\Http\Client\PendingRequest acceptJson() + * @method \Illuminate\Http\Client\PendingRequest asForm() + * @method \Illuminate\Http\Client\PendingRequest asJson() + * @method \Illuminate\Http\Client\PendingRequest asMultipart() + * @method \Illuminate\Http\Client\PendingRequest async() + * @method \Illuminate\Http\Client\PendingRequest attach(string|array $name, string|resource $contents = '', string|null $filename = null, array $headers = []) + * @method \Illuminate\Http\Client\PendingRequest baseUrl(string $url) + * @method \Illuminate\Http\Client\PendingRequest beforeSending(callable $callback) + * @method \Illuminate\Http\Client\PendingRequest bodyFormat(string $format) + * @method \Illuminate\Http\Client\PendingRequest contentType(string $contentType) + * @method \Illuminate\Http\Client\PendingRequest dd() + * @method \Illuminate\Http\Client\PendingRequest dump() + * @method \Illuminate\Http\Client\PendingRequest retry(int $times, int $sleep = 0, ?callable $when = null) + * @method \Illuminate\Http\Client\PendingRequest sink(string|resource $to) + * @method \Illuminate\Http\Client\PendingRequest stub(callable $callback) + * @method \Illuminate\Http\Client\PendingRequest timeout(int $seconds) + * @method \Illuminate\Http\Client\PendingRequest withBasicAuth(string $username, string $password) + * @method \Illuminate\Http\Client\PendingRequest withBody(resource|string $content, string $contentType) + * @method \Illuminate\Http\Client\PendingRequest withCookies(array $cookies, string $domain) + * @method \Illuminate\Http\Client\PendingRequest withDigestAuth(string $username, string $password) + * @method \Illuminate\Http\Client\PendingRequest withHeaders(array $headers) + * @method \Illuminate\Http\Client\PendingRequest withMiddleware(callable $middleware) + * @method \Illuminate\Http\Client\PendingRequest withOptions(array $options) + * @method \Illuminate\Http\Client\PendingRequest withToken(string $token, string $type = 'Bearer') + * @method \Illuminate\Http\Client\PendingRequest withUserAgent(string $userAgent) + * @method \Illuminate\Http\Client\PendingRequest withoutRedirecting() + * @method \Illuminate\Http\Client\PendingRequest withoutVerifying() + * @method array pool(callable $callback) + * @method \Illuminate\Http\Client\Response delete(string $url, array $data = []) + * @method \Illuminate\Http\Client\Response get(string $url, array|string|null $query = null) + * @method \Illuminate\Http\Client\Response head(string $url, array|string|null $query = null) + * @method \Illuminate\Http\Client\Response patch(string $url, array $data = []) + * @method \Illuminate\Http\Client\Response post(string $url, array $data = []) + * @method \Illuminate\Http\Client\Response put(string $url, array $data = []) + * @method \Illuminate\Http\Client\Response send(string $method, string $url, array $options = []) + * + * @see \Illuminate\Http\Client\PendingRequest + */ +class Factory +{ + use Macroable { + __call as macroCall; + } + + /** + * The event dispatcher implementation. + * + * @var \Illuminate\Contracts\Events\Dispatcher|null + */ + protected $dispatcher; + + /** + * The stub callables that will handle requests. + * + * @var \Illuminate\Support\Collection + */ + protected $stubCallbacks; + + /** + * Indicates if the factory is recording requests and responses. + * + * @var bool + */ + protected $recording = false; + + /** + * The recorded response array. + * + * @var array + */ + protected $recorded = []; + + /** + * All created response sequences. + * + * @var array + */ + protected $responseSequences = []; + + /** + * Create a new factory instance. + * + * @param \Illuminate\Contracts\Events\Dispatcher|null $dispatcher + * @return void + */ + public function __construct(Dispatcher $dispatcher = null) + { + $this->dispatcher = $dispatcher; + + $this->stubCallbacks = collect(); + } + + /** + * Create a new response instance for use during stubbing. + * + * @param array|string $body + * @param int $status + * @param array $headers + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public static function response($body = null, $status = 200, $headers = []) + { + if (is_array($body)) { + $body = json_encode($body); + + $headers['Content-Type'] = 'application/json'; + } + + $response = new Psr7Response($status, $headers, $body); + + return class_exists(\GuzzleHttp\Promise\Create::class) + ? \GuzzleHttp\Promise\Create::promiseFor($response) + : \GuzzleHttp\Promise\promise_for($response); + } + + /** + * Get an invokable object that returns a sequence of responses in order for use during stubbing. + * + * @param array $responses + * @return \Illuminate\Http\Client\ResponseSequence + */ + public function sequence(array $responses = []) + { + return $this->responseSequences[] = new ResponseSequence($responses); + } + + /** + * Register a stub callable that will intercept requests and be able to return stub responses. + * + * @param callable|array $callback + * @return $this + */ + public function fake($callback = null) + { + $this->record(); + + $this->recorded = []; + + if (is_null($callback)) { + $callback = function () { + return static::response(); + }; + } + + if (is_array($callback)) { + foreach ($callback as $url => $callable) { + $this->stubUrl($url, $callable); + } + + return $this; + } + + $this->stubCallbacks = $this->stubCallbacks->merge(collect([ + function ($request, $options) use ($callback) { + $response = $callback instanceof Closure + ? $callback($request, $options) + : $callback; + + if ($response instanceof PromiseInterface) { + $options['on_stats'](new TransferStats( + $request->toPsrRequest(), + $response->wait(), + )); + } + + return $response; + }, + ])); + + return $this; + } + + /** + * Register a response sequence for the given URL pattern. + * + * @param string $url + * @return \Illuminate\Http\Client\ResponseSequence + */ + public function fakeSequence($url = '*') + { + return tap($this->sequence(), function ($sequence) use ($url) { + $this->fake([$url => $sequence]); + }); + } + + /** + * Stub the given URL using the given callback. + * + * @param string $url + * @param \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|callable $callback + * @return $this + */ + public function stubUrl($url, $callback) + { + return $this->fake(function ($request, $options) use ($url, $callback) { + if (! Str::is(Str::start($url, '*'), $request->url())) { + return; + } + + return $callback instanceof Closure || $callback instanceof ResponseSequence + ? $callback($request, $options) + : $callback; + }); + } + + /** + * Begin recording request / response pairs. + * + * @return $this + */ + protected function record() + { + $this->recording = true; + + return $this; + } + + /** + * Record a request response pair. + * + * @param \Illuminate\Http\Client\Request $request + * @param \Illuminate\Http\Client\Response $response + * @return void + */ + public function recordRequestResponsePair($request, $response) + { + if ($this->recording) { + $this->recorded[] = [$request, $response]; + } + } + + /** + * Assert that a request / response pair was recorded matching a given truth test. + * + * @param callable $callback + * @return void + */ + public function assertSent($callback) + { + PHPUnit::assertTrue( + $this->recorded($callback)->count() > 0, + 'An expected request was not recorded.' + ); + } + + /** + * Assert that the given request was sent in the given order. + * + * @param array $callbacks + * @return void + */ + public function assertSentInOrder($callbacks) + { + $this->assertSentCount(count($callbacks)); + + foreach ($callbacks as $index => $url) { + $callback = is_callable($url) ? $url : function ($request) use ($url) { + return $request->url() == $url; + }; + + PHPUnit::assertTrue($callback( + $this->recorded[$index][0], + $this->recorded[$index][1] + ), 'An expected request (#'.($index + 1).') was not recorded.'); + } + } + + /** + * Assert that a request / response pair was not recorded matching a given truth test. + * + * @param callable $callback + * @return void + */ + public function assertNotSent($callback) + { + PHPUnit::assertFalse( + $this->recorded($callback)->count() > 0, + 'Unexpected request was recorded.' + ); + } + + /** + * Assert that no request / response pair was recorded. + * + * @return void + */ + public function assertNothingSent() + { + PHPUnit::assertEmpty( + $this->recorded, + 'Requests were recorded.' + ); + } + + /** + * Assert how many requests have been recorded. + * + * @param int $count + * @return void + */ + public function assertSentCount($count) + { + PHPUnit::assertCount($count, $this->recorded); + } + + /** + * Assert that every created response sequence is empty. + * + * @return void + */ + public function assertSequencesAreEmpty() + { + foreach ($this->responseSequences as $responseSequence) { + PHPUnit::assertTrue( + $responseSequence->isEmpty(), + 'Not all response sequences are empty.' + ); + } + } + + /** + * Get a collection of the request / response pairs matching the given truth test. + * + * @param callable $callback + * @return \Illuminate\Support\Collection + */ + public function recorded($callback = null) + { + if (empty($this->recorded)) { + return collect(); + } + + $callback = $callback ?: function () { + return true; + }; + + return collect($this->recorded)->filter(function ($pair) use ($callback) { + return $callback($pair[0], $pair[1]); + }); + } + + /** + * Create a new pending request instance for this factory. + * + * @return \Illuminate\Http\Client\PendingRequest + */ + protected function newPendingRequest() + { + return new PendingRequest($this); + } + + /** + * Get the current event dispatcher implementation. + * + * @return \Illuminate\Contracts\Events\Dispatcher|null + */ + public function getDispatcher() + { + return $this->dispatcher; + } + + /** + * Execute a method against a new pending request instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return tap($this->newPendingRequest(), function ($request) { + $request->stub($this->stubCallbacks); + })->{$method}(...$parameters); + } +} diff --git a/src/Illuminate/Http/Client/HttpClientException.php b/src/Illuminate/Http/Client/HttpClientException.php new file mode 100644 index 0000000000000000000000000000000000000000..b15b8d30fe9295f579f87e9f9799ce57a44d3e76 --- /dev/null +++ b/src/Illuminate/Http/Client/HttpClientException.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Http\Client; + +use Exception; + +class HttpClientException extends Exception +{ + // +} diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..fdf5f06d4d8a1f3a8b390a7c5204cc9a920b1ad9 --- /dev/null +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -0,0 +1,1134 @@ +<?php + +namespace Illuminate\Http\Client; + +use GuzzleHttp\Client; +use GuzzleHttp\Cookie\CookieJar; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\TransferException; +use GuzzleHttp\HandlerStack; +use Illuminate\Http\Client\Events\ConnectionFailed; +use Illuminate\Http\Client\Events\RequestSending; +use Illuminate\Http\Client\Events\ResponseReceived; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\Conditionable; +use Illuminate\Support\Traits\Macroable; +use Psr\Http\Message\MessageInterface; +use Psr\Http\Message\RequestInterface; +use Symfony\Component\VarDumper\VarDumper; + +class PendingRequest +{ + use Conditionable, Macroable; + + /** + * The factory instance. + * + * @var \Illuminate\Http\Client\Factory|null + */ + protected $factory; + + /** + * The Guzzle client instance. + * + * @var \GuzzleHttp\Client + */ + protected $client; + + /** + * The base URL for the request. + * + * @var string + */ + protected $baseUrl = ''; + + /** + * The request body format. + * + * @var string + */ + protected $bodyFormat; + + /** + * The raw body for the request. + * + * @var string + */ + protected $pendingBody; + + /** + * The pending files for the request. + * + * @var array + */ + protected $pendingFiles = []; + + /** + * The request cookies. + * + * @var array + */ + protected $cookies; + + /** + * The transfer stats for the request. + * + * \GuzzleHttp\TransferStats + */ + protected $transferStats; + + /** + * The request options. + * + * @var array + */ + protected $options = []; + + /** + * The number of times to try the request. + * + * @var int + */ + protected $tries = 1; + + /** + * The number of milliseconds to wait between retries. + * + * @var int + */ + protected $retryDelay = 100; + + /** + * The callback that will determine if the request should be retried. + * + * @var callable|null + */ + protected $retryWhenCallback = null; + + /** + * The callbacks that should execute before the request is sent. + * + * @var \Illuminate\Support\Collection + */ + protected $beforeSendingCallbacks; + + /** + * The stub callables that will handle requests. + * + * @var \Illuminate\Support\Collection|null + */ + protected $stubCallbacks; + + /** + * The middleware callables added by users that will handle requests. + * + * @var \Illuminate\Support\Collection + */ + protected $middleware; + + /** + * Whether the requests should be asynchronous. + * + * @var bool + */ + protected $async = false; + + /** + * The pending request promise. + * + * @var \GuzzleHttp\Promise\PromiseInterface + */ + protected $promise; + + /** + * The sent request object, if a request has been made. + * + * @var \Illuminate\Http\Client\Request|null + */ + protected $request; + + /** + * The Guzzle request options that are mergable via array_merge_recursive. + * + * @var array + */ + protected $mergableOptions = [ + 'cookies', + 'form_params', + 'headers', + 'json', + 'multipart', + 'query', + ]; + + /** + * Create a new HTTP Client instance. + * + * @param \Illuminate\Http\Client\Factory|null $factory + * @return void + */ + public function __construct(Factory $factory = null) + { + $this->factory = $factory; + $this->middleware = new Collection; + + $this->asJson(); + + $this->options = [ + 'http_errors' => false, + ]; + + $this->beforeSendingCallbacks = collect([function (Request $request, array $options, PendingRequest $pendingRequest) { + $pendingRequest->request = $request; + $pendingRequest->cookies = $options['cookies']; + + $pendingRequest->dispatchRequestSendingEvent(); + }]); + } + + /** + * Set the base URL for the pending request. + * + * @param string $url + * @return $this + */ + public function baseUrl(string $url) + { + $this->baseUrl = $url; + + return $this; + } + + /** + * Attach a raw body to the request. + * + * @param string $content + * @param string $contentType + * @return $this + */ + public function withBody($content, $contentType) + { + $this->bodyFormat('body'); + + $this->pendingBody = $content; + + $this->contentType($contentType); + + return $this; + } + + /** + * Indicate the request contains JSON. + * + * @return $this + */ + public function asJson() + { + return $this->bodyFormat('json')->contentType('application/json'); + } + + /** + * Indicate the request contains form parameters. + * + * @return $this + */ + public function asForm() + { + return $this->bodyFormat('form_params')->contentType('application/x-www-form-urlencoded'); + } + + /** + * Attach a file to the request. + * + * @param string|array $name + * @param string|resource $contents + * @param string|null $filename + * @param array $headers + * @return $this + */ + public function attach($name, $contents = '', $filename = null, array $headers = []) + { + if (is_array($name)) { + foreach ($name as $file) { + $this->attach(...$file); + } + + return $this; + } + + $this->asMultipart(); + + $this->pendingFiles[] = array_filter([ + 'name' => $name, + 'contents' => $contents, + 'headers' => $headers, + 'filename' => $filename, + ]); + + return $this; + } + + /** + * Indicate the request is a multi-part form request. + * + * @return $this + */ + public function asMultipart() + { + return $this->bodyFormat('multipart'); + } + + /** + * Specify the body format of the request. + * + * @param string $format + * @return $this + */ + public function bodyFormat(string $format) + { + return tap($this, function ($request) use ($format) { + $this->bodyFormat = $format; + }); + } + + /** + * Specify the request's content type. + * + * @param string $contentType + * @return $this + */ + public function contentType(string $contentType) + { + return $this->withHeaders(['Content-Type' => $contentType]); + } + + /** + * Indicate that JSON should be returned by the server. + * + * @return $this + */ + public function acceptJson() + { + return $this->accept('application/json'); + } + + /** + * Indicate the type of content that should be returned by the server. + * + * @param string $contentType + * @return $this + */ + public function accept($contentType) + { + return $this->withHeaders(['Accept' => $contentType]); + } + + /** + * Add the given headers to the request. + * + * @param array $headers + * @return $this + */ + public function withHeaders(array $headers) + { + return tap($this, function ($request) use ($headers) { + return $this->options = array_merge_recursive($this->options, [ + 'headers' => $headers, + ]); + }); + } + + /** + * Specify the basic authentication username and password for the request. + * + * @param string $username + * @param string $password + * @return $this + */ + public function withBasicAuth(string $username, string $password) + { + return tap($this, function ($request) use ($username, $password) { + return $this->options['auth'] = [$username, $password]; + }); + } + + /** + * Specify the digest authentication username and password for the request. + * + * @param string $username + * @param string $password + * @return $this + */ + public function withDigestAuth($username, $password) + { + return tap($this, function ($request) use ($username, $password) { + return $this->options['auth'] = [$username, $password, 'digest']; + }); + } + + /** + * Specify an authorization token for the request. + * + * @param string $token + * @param string $type + * @return $this + */ + public function withToken($token, $type = 'Bearer') + { + return tap($this, function ($request) use ($token, $type) { + return $this->options['headers']['Authorization'] = trim($type.' '.$token); + }); + } + + /** + * Specify the user agent for the request. + * + * @param string $userAgent + * @return $this + */ + public function withUserAgent($userAgent) + { + return tap($this, function ($request) use ($userAgent) { + return $this->options['headers']['User-Agent'] = trim($userAgent); + }); + } + + /** + * Specify the cookies that should be included with the request. + * + * @param array $cookies + * @param string $domain + * @return $this + */ + public function withCookies(array $cookies, string $domain) + { + return tap($this, function ($request) use ($cookies, $domain) { + return $this->options = array_merge_recursive($this->options, [ + 'cookies' => CookieJar::fromArray($cookies, $domain), + ]); + }); + } + + /** + * Indicate that redirects should not be followed. + * + * @return $this + */ + public function withoutRedirecting() + { + return tap($this, function ($request) { + return $this->options['allow_redirects'] = false; + }); + } + + /** + * Indicate that TLS certificates should not be verified. + * + * @return $this + */ + public function withoutVerifying() + { + return tap($this, function ($request) { + return $this->options['verify'] = false; + }); + } + + /** + * Specify the path where the body of the response should be stored. + * + * @param string|resource $to + * @return $this + */ + public function sink($to) + { + return tap($this, function ($request) use ($to) { + return $this->options['sink'] = $to; + }); + } + + /** + * Specify the timeout (in seconds) for the request. + * + * @param int $seconds + * @return $this + */ + public function timeout(int $seconds) + { + return tap($this, function () use ($seconds) { + $this->options['timeout'] = $seconds; + }); + } + + /** + * Specify the number of times the request should be attempted. + * + * @param int $times + * @param int $sleep + * @param callable|null $when + * @return $this + */ + public function retry(int $times, int $sleep = 0, ?callable $when = null) + { + $this->tries = $times; + $this->retryDelay = $sleep; + $this->retryWhenCallback = $when; + + return $this; + } + + /** + * Replace the specified options on the request. + * + * @param array $options + * @return $this + */ + public function withOptions(array $options) + { + return tap($this, function ($request) use ($options) { + return $this->options = array_replace_recursive( + array_merge_recursive($this->options, Arr::only($options, $this->mergableOptions)), + $options + ); + }); + } + + /** + * Add new middleware the client handler stack. + * + * @param callable $middleware + * @return $this + */ + public function withMiddleware(callable $middleware) + { + $this->middleware->push($middleware); + + return $this; + } + + /** + * Add a new "before sending" callback to the request. + * + * @param callable $callback + * @return $this + */ + public function beforeSending($callback) + { + return tap($this, function () use ($callback) { + $this->beforeSendingCallbacks[] = $callback; + }); + } + + /** + * Dump the request before sending. + * + * @return $this + */ + public function dump() + { + $values = func_get_args(); + + return $this->beforeSending(function (Request $request, array $options) use ($values) { + foreach (array_merge($values, [$request, $options]) as $value) { + VarDumper::dump($value); + } + }); + } + + /** + * Dump the request before sending and end the script. + * + * @return $this + */ + public function dd() + { + $values = func_get_args(); + + return $this->beforeSending(function (Request $request, array $options) use ($values) { + foreach (array_merge($values, [$request, $options]) as $value) { + VarDumper::dump($value); + } + + exit(1); + }); + } + + /** + * Issue a GET request to the given URL. + * + * @param string $url + * @param array|string|null $query + * @return \Illuminate\Http\Client\Response + */ + public function get(string $url, $query = null) + { + return $this->send('GET', $url, func_num_args() === 1 ? [] : [ + 'query' => $query, + ]); + } + + /** + * Issue a HEAD request to the given URL. + * + * @param string $url + * @param array|string|null $query + * @return \Illuminate\Http\Client\Response + */ + public function head(string $url, $query = null) + { + return $this->send('HEAD', $url, func_num_args() === 1 ? [] : [ + 'query' => $query, + ]); + } + + /** + * Issue a POST request to the given URL. + * + * @param string $url + * @param array $data + * @return \Illuminate\Http\Client\Response + */ + public function post(string $url, array $data = []) + { + return $this->send('POST', $url, [ + $this->bodyFormat => $data, + ]); + } + + /** + * Issue a PATCH request to the given URL. + * + * @param string $url + * @param array $data + * @return \Illuminate\Http\Client\Response + */ + public function patch($url, $data = []) + { + return $this->send('PATCH', $url, [ + $this->bodyFormat => $data, + ]); + } + + /** + * Issue a PUT request to the given URL. + * + * @param string $url + * @param array $data + * @return \Illuminate\Http\Client\Response + */ + public function put($url, $data = []) + { + return $this->send('PUT', $url, [ + $this->bodyFormat => $data, + ]); + } + + /** + * Issue a DELETE request to the given URL. + * + * @param string $url + * @param array $data + * @return \Illuminate\Http\Client\Response + */ + public function delete($url, $data = []) + { + return $this->send('DELETE', $url, empty($data) ? [] : [ + $this->bodyFormat => $data, + ]); + } + + /** + * Send a pool of asynchronous requests concurrently. + * + * @param callable $callback + * @return array + */ + public function pool(callable $callback) + { + $results = []; + + $requests = tap(new Pool($this->factory), $callback)->getRequests(); + + foreach ($requests as $key => $item) { + $results[$key] = $item instanceof static ? $item->getPromise()->wait() : $item->wait(); + } + + return $results; + } + + /** + * Send the request to the given URL. + * + * @param string $method + * @param string $url + * @param array $options + * @return \Illuminate\Http\Client\Response + * + * @throws \Exception + */ + public function send(string $method, string $url, array $options = []) + { + $url = ltrim(rtrim($this->baseUrl, '/').'/'.ltrim($url, '/'), '/'); + + if (isset($options[$this->bodyFormat])) { + if ($this->bodyFormat === 'multipart') { + $options[$this->bodyFormat] = $this->parseMultipartBodyFormat($options[$this->bodyFormat]); + } elseif ($this->bodyFormat === 'body') { + $options[$this->bodyFormat] = $this->pendingBody; + } + + if (is_array($options[$this->bodyFormat])) { + $options[$this->bodyFormat] = array_merge( + $options[$this->bodyFormat], $this->pendingFiles + ); + } + } else { + $options[$this->bodyFormat] = $this->pendingBody; + } + + [$this->pendingBody, $this->pendingFiles] = [null, []]; + + if ($this->async) { + return $this->makePromise($method, $url, $options); + } + + return retry($this->tries ?? 1, function () use ($method, $url, $options) { + try { + return tap(new Response($this->sendRequest($method, $url, $options)), function ($response) { + $this->populateResponse($response); + + if ($this->tries > 1 && ! $response->successful()) { + $response->throw(); + } + + $this->dispatchResponseReceivedEvent($response); + }); + } catch (ConnectException $e) { + $this->dispatchConnectionFailedEvent(); + + throw new ConnectionException($e->getMessage(), 0, $e); + } + }, $this->retryDelay ?? 100, $this->retryWhenCallback); + } + + /** + * Parse multi-part form data. + * + * @param array $data + * @return array|array[] + */ + protected function parseMultipartBodyFormat(array $data) + { + return collect($data)->map(function ($value, $key) { + return is_array($value) ? $value : ['name' => $key, 'contents' => $value]; + })->values()->all(); + } + + /** + * Send an asynchronous request to the given URL. + * + * @param string $method + * @param string $url + * @param array $options + * @return \GuzzleHttp\Promise\PromiseInterface + */ + protected function makePromise(string $method, string $url, array $options = []) + { + return $this->promise = $this->sendRequest($method, $url, $options) + ->then(function (MessageInterface $message) { + return tap(new Response($message), function ($response) { + $this->populateResponse($response); + $this->dispatchResponseReceivedEvent($response); + }); + }) + ->otherwise(function (TransferException $e) { + return $e instanceof RequestException ? $this->populateResponse(new Response($e->getResponse())) : $e; + }); + } + + /** + * Send a request either synchronously or asynchronously. + * + * @param string $method + * @param string $url + * @param array $options + * @return \Psr\Http\Message\MessageInterface|\GuzzleHttp\Promise\PromiseInterface + * + * @throws \Exception + */ + protected function sendRequest(string $method, string $url, array $options = []) + { + $clientMethod = $this->async ? 'requestAsync' : 'request'; + + $laravelData = $this->parseRequestData($method, $url, $options); + + return $this->buildClient()->$clientMethod($method, $url, $this->mergeOptions([ + 'laravel_data' => $laravelData, + 'on_stats' => function ($transferStats) { + $this->transferStats = $transferStats; + }, + ], $options)); + } + + /** + * Get the request data as an array so that we can attach it to the request for convenient assertions. + * + * @param string $method + * @param string $url + * @param array $options + * @return array + */ + protected function parseRequestData($method, $url, array $options) + { + $laravelData = $options[$this->bodyFormat] ?? $options['query'] ?? []; + + $urlString = Str::of($url); + + if (empty($laravelData) && $method === 'GET' && $urlString->contains('?')) { + $laravelData = (string) $urlString->after('?'); + } + + if (is_string($laravelData)) { + parse_str($laravelData, $parsedData); + + $laravelData = is_array($parsedData) ? $parsedData : []; + } + + return $laravelData; + } + + /** + * Populate the given response with additional data. + * + * @param \Illuminate\Http\Client\Response $response + * @return \Illuminate\Http\Client\Response + */ + protected function populateResponse(Response $response) + { + $response->cookies = $this->cookies; + + $response->transferStats = $this->transferStats; + + return $response; + } + + /** + * Build the Guzzle client. + * + * @return \GuzzleHttp\Client + */ + public function buildClient() + { + return $this->requestsReusableClient() + ? $this->getReusableClient() + : $this->createClient($this->buildHandlerStack()); + } + + /** + * Determine if a reusable client is required. + * + * @return bool + */ + protected function requestsReusableClient() + { + return ! is_null($this->client) || $this->async; + } + + /** + * Retrieve a reusable Guzzle client. + * + * @return \GuzzleHttp\Client + */ + protected function getReusableClient() + { + return $this->client = $this->client ?: $this->createClient($this->buildHandlerStack()); + } + + /** + * Create new Guzzle client. + * + * @param \GuzzleHttp\HandlerStack $handlerStack + * @return \GuzzleHttp\Client + */ + public function createClient($handlerStack) + { + return new Client([ + 'handler' => $handlerStack, + 'cookies' => true, + ]); + } + + /** + * Build the Guzzle client handler stack. + * + * @return \GuzzleHttp\HandlerStack + */ + public function buildHandlerStack() + { + return $this->pushHandlers(HandlerStack::create()); + } + + /** + * Add the necessary handlers to the given handler stack. + * + * @param \GuzzleHttp\HandlerStack $handlerStack + * @return \GuzzleHttp\HandlerStack + */ + public function pushHandlers($handlerStack) + { + return tap($handlerStack, function ($stack) { + $stack->push($this->buildBeforeSendingHandler()); + $stack->push($this->buildRecorderHandler()); + $stack->push($this->buildStubHandler()); + + $this->middleware->each(function ($middleware) use ($stack) { + $stack->push($middleware); + }); + }); + } + + /** + * Build the before sending handler. + * + * @return \Closure + */ + public function buildBeforeSendingHandler() + { + return function ($handler) { + return function ($request, $options) use ($handler) { + return $handler($this->runBeforeSendingCallbacks($request, $options), $options); + }; + }; + } + + /** + * Build the recorder handler. + * + * @return \Closure + */ + public function buildRecorderHandler() + { + return function ($handler) { + return function ($request, $options) use ($handler) { + $promise = $handler($request, $options); + + return $promise->then(function ($response) use ($request, $options) { + optional($this->factory)->recordRequestResponsePair( + (new Request($request))->withData($options['laravel_data']), + new Response($response) + ); + + return $response; + }); + }; + }; + } + + /** + * Build the stub handler. + * + * @return \Closure + */ + public function buildStubHandler() + { + return function ($handler) { + return function ($request, $options) use ($handler) { + $response = ($this->stubCallbacks ?? collect()) + ->map + ->__invoke((new Request($request))->withData($options['laravel_data']), $options) + ->filter() + ->first(); + + if (is_null($response)) { + return $handler($request, $options); + } + + $response = is_array($response) ? Factory::response($response) : $response; + + $sink = $options['sink'] ?? null; + + if ($sink) { + $response->then($this->sinkStubHandler($sink)); + } + + return $response; + }; + }; + } + + /** + * Get the sink stub handler callback. + * + * @param string $sink + * @return \Closure + */ + protected function sinkStubHandler($sink) + { + return function ($response) use ($sink) { + $body = $response->getBody()->getContents(); + + if (is_string($sink)) { + file_put_contents($sink, $body); + + return; + } + + fwrite($sink, $body); + rewind($sink); + }; + } + + /** + * Execute the "before sending" callbacks. + * + * @param \GuzzleHttp\Psr7\RequestInterface $request + * @param array $options + * @return \GuzzleHttp\Psr7\RequestInterface + */ + public function runBeforeSendingCallbacks($request, array $options) + { + return tap($request, function (&$request) use ($options) { + $this->beforeSendingCallbacks->each(function ($callback) use (&$request, $options) { + $callbackResult = call_user_func( + $callback, (new Request($request))->withData($options['laravel_data']), $options, $this + ); + + if ($callbackResult instanceof RequestInterface) { + $request = $callbackResult; + } elseif ($callbackResult instanceof Request) { + $request = $callbackResult->toPsrRequest(); + } + }); + }); + } + + /** + * Replace the given options with the current request options. + * + * @param array $options + * @return array + */ + public function mergeOptions(...$options) + { + return array_replace_recursive( + array_merge_recursive($this->options, Arr::only($options, $this->mergableOptions)), + ...$options + ); + } + + /** + * Register a stub callable that will intercept requests and be able to return stub responses. + * + * @param callable $callback + * @return $this + */ + public function stub($callback) + { + $this->stubCallbacks = collect($callback); + + return $this; + } + + /** + * Toggle asynchronicity in requests. + * + * @param bool $async + * @return $this + */ + public function async(bool $async = true) + { + $this->async = $async; + + return $this; + } + + /** + * Retrieve the pending request promise. + * + * @return \GuzzleHttp\Promise\PromiseInterface|null + */ + public function getPromise() + { + return $this->promise; + } + + /** + * Dispatch the RequestSending event if a dispatcher is available. + * + * @return void + */ + protected function dispatchRequestSendingEvent() + { + if ($dispatcher = optional($this->factory)->getDispatcher()) { + $dispatcher->dispatch(new RequestSending($this->request)); + } + } + + /** + * Dispatch the ResponseReceived event if a dispatcher is available. + * + * @param \Illuminate\Http\Client\Response $response + * @return void + */ + protected function dispatchResponseReceivedEvent(Response $response) + { + if (! ($dispatcher = optional($this->factory)->getDispatcher()) || + ! $this->request) { + return; + } + + $dispatcher->dispatch(new ResponseReceived($this->request, $response)); + } + + /** + * Dispatch the ConnectionFailed event if a dispatcher is available. + * + * @return void + */ + protected function dispatchConnectionFailedEvent() + { + if ($dispatcher = optional($this->factory)->getDispatcher()) { + $dispatcher->dispatch(new ConnectionFailed($this->request)); + } + } + + /** + * Set the client instance. + * + * @param \GuzzleHttp\Client $client + * @return $this + */ + public function setClient(Client $client) + { + $this->client = $client; + + return $this; + } + + /** + * Create a new client instance using the given handler. + * + * @param callable $handler + * @return $this + */ + public function setHandler($handler) + { + $this->client = $this->createClient( + $this->pushHandlers(HandlerStack::create($handler)) + ); + + return $this; + } + + /** + * Get the pending request options. + * + * @return array + */ + public function getOptions() + { + return $this->options; + } +} diff --git a/src/Illuminate/Http/Client/Pool.php b/src/Illuminate/Http/Client/Pool.php new file mode 100644 index 0000000000000000000000000000000000000000..bedffcb1d6523e11c362223e12730bf0cca5bdb5 --- /dev/null +++ b/src/Illuminate/Http/Client/Pool.php @@ -0,0 +1,92 @@ +<?php + +namespace Illuminate\Http\Client; + +use GuzzleHttp\Utils; + +/** + * @mixin \Illuminate\Http\Client\Factory + */ +class Pool +{ + /** + * The factory instance. + * + * @var \Illuminate\Http\Client\Factory + */ + protected $factory; + + /** + * The handler function for the Guzzle client. + * + * @var callable + */ + protected $handler; + + /** + * The pool of requests. + * + * @var array + */ + protected $pool = []; + + /** + * Create a new requests pool. + * + * @param \Illuminate\Http\Client\Factory|null $factory + * @return void + */ + public function __construct(Factory $factory = null) + { + $this->factory = $factory ?: new Factory(); + + if (method_exists(Utils::class, 'chooseHandler')) { + $this->handler = Utils::chooseHandler(); + } else { + $this->handler = \GuzzleHttp\choose_handler(); + } + } + + /** + * Add a request to the pool with a key. + * + * @param string $key + * @return \Illuminate\Http\Client\PendingRequest + */ + public function as(string $key) + { + return $this->pool[$key] = $this->asyncRequest(); + } + + /** + * Retrieve a new async pending request. + * + * @return \Illuminate\Http\Client\PendingRequest + */ + protected function asyncRequest() + { + return $this->factory->setHandler($this->handler)->async(); + } + + /** + * Retrieve the requests in the pool. + * + * @return array + */ + public function getRequests() + { + return $this->pool; + } + + /** + * Add a request to the pool with a numeric index. + * + * @param string $method + * @param array $parameters + * @return \Illuminate\Http\Client\PendingRequest + */ + public function __call($method, $parameters) + { + return $this->pool[] = $this->asyncRequest()->$method(...$parameters); + } +} diff --git a/src/Illuminate/Http/Client/Request.php b/src/Illuminate/Http/Client/Request.php new file mode 100644 index 0000000000000000000000000000000000000000..0e493f1fa9689064a651d63c235340d49c47524a --- /dev/null +++ b/src/Illuminate/Http/Client/Request.php @@ -0,0 +1,310 @@ +<?php + +namespace Illuminate\Http\Client; + +use ArrayAccess; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\Macroable; +use LogicException; + +class Request implements ArrayAccess +{ + use Macroable; + + /** + * The underlying PSR request. + * + * @var \Psr\Http\Message\RequestInterface + */ + protected $request; + + /** + * The decoded payload for the request. + * + * @var array + */ + protected $data; + + /** + * Create a new request instance. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return void + */ + public function __construct($request) + { + $this->request = $request; + } + + /** + * Get the request method. + * + * @return string + */ + public function method() + { + return $this->request->getMethod(); + } + + /** + * Get the URL of the request. + * + * @return string + */ + public function url() + { + return (string) $this->request->getUri(); + } + + /** + * Determine if the request has a given header. + * + * @param string $key + * @param mixed $value + * @return bool + */ + public function hasHeader($key, $value = null) + { + if (is_null($value)) { + return ! empty($this->request->getHeaders()[$key]); + } + + $headers = $this->headers(); + + if (! Arr::has($headers, $key)) { + return false; + } + + $value = is_array($value) ? $value : [$value]; + + return empty(array_diff($value, $headers[$key])); + } + + /** + * Determine if the request has the given headers. + * + * @param array|string $headers + * @return bool + */ + public function hasHeaders($headers) + { + if (is_string($headers)) { + $headers = [$headers => null]; + } + + foreach ($headers as $key => $value) { + if (! $this->hasHeader($key, $value)) { + return false; + } + } + + return true; + } + + /** + * Get the values for the header with the given name. + * + * @param string $key + * @return array + */ + public function header($key) + { + return Arr::get($this->headers(), $key, []); + } + + /** + * Get the request headers. + * + * @return array + */ + public function headers() + { + return $this->request->getHeaders(); + } + + /** + * Get the body of the request. + * + * @return string + */ + public function body() + { + return (string) $this->request->getBody(); + } + + /** + * Determine if the request contains the given file. + * + * @param string $name + * @param string|null $value + * @param string|null $filename + * @return bool + */ + public function hasFile($name, $value = null, $filename = null) + { + if (! $this->isMultipart()) { + return false; + } + + return collect($this->data)->reject(function ($file) use ($name, $value, $filename) { + return $file['name'] != $name || + ($value && $file['contents'] != $value) || + ($filename && $file['filename'] != $filename); + })->count() > 0; + } + + /** + * Get the request's data (form parameters or JSON). + * + * @return array + */ + public function data() + { + if ($this->isForm()) { + return $this->parameters(); + } elseif ($this->isJson()) { + return $this->json(); + } + + return $this->data ?? []; + } + + /** + * Get the request's form parameters. + * + * @return array + */ + protected function parameters() + { + if (! $this->data) { + parse_str($this->body(), $parameters); + + $this->data = $parameters; + } + + return $this->data; + } + + /** + * Get the JSON decoded body of the request. + * + * @return array + */ + protected function json() + { + if (! $this->data) { + $this->data = json_decode($this->body(), true); + } + + return $this->data; + } + + /** + * Determine if the request is simple form data. + * + * @return bool + */ + public function isForm() + { + return $this->hasHeader('Content-Type', 'application/x-www-form-urlencoded'); + } + + /** + * Determine if the request is JSON. + * + * @return bool + */ + public function isJson() + { + return $this->hasHeader('Content-Type') && + Str::contains($this->header('Content-Type')[0], 'json'); + } + + /** + * Determine if the request is multipart. + * + * @return bool + */ + public function isMultipart() + { + return $this->hasHeader('Content-Type') && + Str::contains($this->header('Content-Type')[0], 'multipart'); + } + + /** + * Set the decoded data on the request. + * + * @param array $data + * @return $this + */ + public function withData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Get the underlying PSR compliant request instance. + * + * @return \Psr\Http\Message\RequestInterface + */ + public function toPsrRequest() + { + return $this->request; + } + + /** + * Determine if the given offset exists. + * + * @param string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->data()[$offset]); + } + + /** + * Get the value for a given offset. + * + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->data()[$offset]; + } + + /** + * Set the value at the given offset. + * + * @param string $offset + * @param mixed $value + * @return void + * + * @throws \LogicException + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + throw new LogicException('Request data may not be mutated using array access.'); + } + + /** + * Unset the value at the given offset. + * + * @param string $offset + * @return void + * + * @throws \LogicException + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new LogicException('Request data may not be mutated using array access.'); + } +} diff --git a/src/Illuminate/Http/Client/RequestException.php b/src/Illuminate/Http/Client/RequestException.php new file mode 100644 index 0000000000000000000000000000000000000000..fa4f418398aebcc6c187f829757712afd7b1f5a7 --- /dev/null +++ b/src/Illuminate/Http/Client/RequestException.php @@ -0,0 +1,43 @@ +<?php + +namespace Illuminate\Http\Client; + +class RequestException extends HttpClientException +{ + /** + * The response instance. + * + * @var \Illuminate\Http\Client\Response + */ + public $response; + + /** + * Create a new exception instance. + * + * @param \Illuminate\Http\Client\Response $response + * @return void + */ + public function __construct(Response $response) + { + parent::__construct($this->prepareMessage($response), $response->status()); + + $this->response = $response; + } + + /** + * Prepare the exception message. + * + * @param \Illuminate\Http\Client\Response $response + * @return string + */ + protected function prepareMessage(Response $response) + { + $message = "HTTP request returned status code {$response->status()}"; + + $summary = class_exists(\GuzzleHttp\Psr7\Message::class) + ? \GuzzleHttp\Psr7\Message::bodySummary($response->toPsrResponse()) + : \GuzzleHttp\Psr7\get_message_body_summary($response->toPsrResponse()); + + return is_null($summary) ? $message : $message .= ":\n{$summary}\n"; + } +} diff --git a/src/Illuminate/Http/Client/Response.php b/src/Illuminate/Http/Client/Response.php new file mode 100644 index 0000000000000000000000000000000000000000..703b3570dff33eb0ff81259823d407b80e9f887b --- /dev/null +++ b/src/Illuminate/Http/Client/Response.php @@ -0,0 +1,404 @@ +<?php + +namespace Illuminate\Http\Client; + +use ArrayAccess; +use Illuminate\Support\Collection; +use Illuminate\Support\Traits\Macroable; +use LogicException; + +class Response implements ArrayAccess +{ + use Macroable { + __call as macroCall; + } + + /** + * The underlying PSR response. + * + * @var \Psr\Http\Message\ResponseInterface + */ + protected $response; + + /** + * The decoded JSON response. + * + * @var array + */ + protected $decoded; + + /** + * Create a new response instance. + * + * @param \Psr\Http\Message\MessageInterface $response + * @return void + */ + public function __construct($response) + { + $this->response = $response; + } + + /** + * Get the body of the response. + * + * @return string + */ + public function body() + { + return (string) $this->response->getBody(); + } + + /** + * Get the JSON decoded body of the response as an array or scalar value. + * + * @param string|null $key + * @param mixed $default + * @return mixed + */ + public function json($key = null, $default = null) + { + if (! $this->decoded) { + $this->decoded = json_decode($this->body(), true); + } + + if (is_null($key)) { + return $this->decoded; + } + + return data_get($this->decoded, $key, $default); + } + + /** + * Get the JSON decoded body of the response as an object. + * + * @return object + */ + public function object() + { + return json_decode($this->body(), false); + } + + /** + * Get the JSON decoded body of the response as a collection. + * + * @param string|null $key + * @return \Illuminate\Support\Collection + */ + public function collect($key = null) + { + return Collection::make($this->json($key)); + } + + /** + * Get a header from the response. + * + * @param string $header + * @return string + */ + public function header(string $header) + { + return $this->response->getHeaderLine($header); + } + + /** + * Get the headers from the response. + * + * @return array + */ + public function headers() + { + return $this->response->getHeaders(); + } + + /** + * Get the status code of the response. + * + * @return int + */ + public function status() + { + return (int) $this->response->getStatusCode(); + } + + /** + * Get the reason phrase of the response. + * + * @return string + */ + public function reason() + { + return $this->response->getReasonPhrase(); + } + + /** + * Get the effective URI of the response. + * + * @return \Psr\Http\Message\UriInterface|null + */ + public function effectiveUri() + { + return optional($this->transferStats)->getEffectiveUri(); + } + + /** + * Determine if the request was successful. + * + * @return bool + */ + public function successful() + { + return $this->status() >= 200 && $this->status() < 300; + } + + /** + * Determine if the response code was "OK". + * + * @return bool + */ + public function ok() + { + return $this->status() === 200; + } + + /** + * Determine if the response was a redirect. + * + * @return bool + */ + public function redirect() + { + return $this->status() >= 300 && $this->status() < 400; + } + + /** + * Determine if the response was a 401 "Unauthorized" response. + * + * @return bool + */ + public function unauthorized() + { + return $this->status() === 401; + } + + /** + * Determine if the response was a 403 "Forbidden" response. + * + * @return bool + */ + public function forbidden() + { + return $this->status() === 403; + } + + /** + * Determine if the response indicates a client or server error occurred. + * + * @return bool + */ + public function failed() + { + return $this->serverError() || $this->clientError(); + } + + /** + * Determine if the response indicates a client error occurred. + * + * @return bool + */ + public function clientError() + { + return $this->status() >= 400 && $this->status() < 500; + } + + /** + * Determine if the response indicates a server error occurred. + * + * @return bool + */ + public function serverError() + { + return $this->status() >= 500; + } + + /** + * Execute the given callback if there was a server or client error. + * + * @param callable $callback + * @return $this + */ + public function onError(callable $callback) + { + if ($this->failed()) { + $callback($this); + } + + return $this; + } + + /** + * Get the response cookies. + * + * @return \GuzzleHttp\Cookie\CookieJar + */ + public function cookies() + { + return $this->cookies; + } + + /** + * Get the handler stats of the response. + * + * @return array + */ + public function handlerStats() + { + return optional($this->transferStats)->getHandlerStats() ?? []; + } + + /** + * Close the stream and any underlying resources. + * + * @return $this + */ + public function close() + { + $this->response->getBody()->close(); + + return $this; + } + + /** + * Get the underlying PSR response for the response. + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function toPsrResponse() + { + return $this->response; + } + + /** + * Create an exception if a server or client error occurred. + * + * @return \Illuminate\Http\Client\RequestException|null + */ + public function toException() + { + if ($this->failed()) { + return new RequestException($this); + } + } + + /** + * Throw an exception if a server or client error occurred. + * + * @param \Closure|null $callback + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throw() + { + $callback = func_get_args()[0] ?? null; + + if ($this->failed()) { + throw tap($this->toException(), function ($exception) use ($callback) { + if ($callback && is_callable($callback)) { + $callback($this, $exception); + } + }); + } + + return $this; + } + + /** + * Throw an exception if a server or client error occurred and the given condition evaluates to true. + * + * @param bool $condition + * @return $this + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function throwIf($condition) + { + return $condition ? $this->throw() : $this; + } + + /** + * Determine if the given offset exists. + * + * @param string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->json()[$offset]); + } + + /** + * Get the value for a given offset. + * + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->json()[$offset]; + } + + /** + * Set the value at the given offset. + * + * @param string $offset + * @param mixed $value + * @return void + * + * @throws \LogicException + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + throw new LogicException('Response data may not be mutated using array access.'); + } + + /** + * Unset the value at the given offset. + * + * @param string $offset + * @return void + * + * @throws \LogicException + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new LogicException('Response data may not be mutated using array access.'); + } + + /** + * Get the body of the response. + * + * @return string + */ + public function __toString() + { + return $this->body(); + } + + /** + * Dynamically proxy other methods to the underlying response. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return static::hasMacro($method) + ? $this->macroCall($method, $parameters) + : $this->response->{$method}(...$parameters); + } +} diff --git a/src/Illuminate/Http/Client/ResponseSequence.php b/src/Illuminate/Http/Client/ResponseSequence.php new file mode 100644 index 0000000000000000000000000000000000000000..dcf8633a3c0976cf894a30e5206bff0281f3370f --- /dev/null +++ b/src/Illuminate/Http/Client/ResponseSequence.php @@ -0,0 +1,158 @@ +<?php + +namespace Illuminate\Http\Client; + +use Illuminate\Support\Traits\Macroable; +use OutOfBoundsException; + +class ResponseSequence +{ + use Macroable; + + /** + * The responses in the sequence. + * + * @var array + */ + protected $responses; + + /** + * Indicates that invoking this sequence when it is empty should throw an exception. + * + * @var bool + */ + protected $failWhenEmpty = true; + + /** + * The response that should be returned when the sequence is empty. + * + * @var \GuzzleHttp\Promise\PromiseInterface + */ + protected $emptyResponse; + + /** + * Create a new response sequence. + * + * @param array $responses + * @return void + */ + public function __construct(array $responses) + { + $this->responses = $responses; + } + + /** + * Push a response to the sequence. + * + * @param string|array $body + * @param int $status + * @param array $headers + * @return $this + */ + public function push($body = '', int $status = 200, array $headers = []) + { + $body = is_array($body) ? json_encode($body) : $body; + + return $this->pushResponse( + Factory::response($body, $status, $headers) + ); + } + + /** + * Push a response with the given status code to the sequence. + * + * @param int $status + * @param array $headers + * @return $this + */ + public function pushStatus(int $status, array $headers = []) + { + return $this->pushResponse( + Factory::response('', $status, $headers) + ); + } + + /** + * Push response with the contents of a file as the body to the sequence. + * + * @param string $filePath + * @param int $status + * @param array $headers + * @return $this + */ + public function pushFile(string $filePath, int $status = 200, array $headers = []) + { + $string = file_get_contents($filePath); + + return $this->pushResponse( + Factory::response($string, $status, $headers) + ); + } + + /** + * Push a response to the sequence. + * + * @param mixed $response + * @return $this + */ + public function pushResponse($response) + { + $this->responses[] = $response; + + return $this; + } + + /** + * Make the sequence return a default response when it is empty. + * + * @param \GuzzleHttp\Promise\PromiseInterface|\Closure $response + * @return $this + */ + public function whenEmpty($response) + { + $this->failWhenEmpty = false; + $this->emptyResponse = $response; + + return $this; + } + + /** + * Make the sequence return a default response when it is empty. + * + * @return $this + */ + public function dontFailWhenEmpty() + { + return $this->whenEmpty(Factory::response()); + } + + /** + * Indicate that this sequence has depleted all of its responses. + * + * @return bool + */ + public function isEmpty() + { + return count($this->responses) === 0; + } + + /** + * Get the next response in the sequence. + * + * @return mixed + * + * @throws \OutOfBoundsException + */ + public function __invoke() + { + if ($this->failWhenEmpty && count($this->responses) === 0) { + throw new OutOfBoundsException('A request was made, but the response sequence is empty.'); + } + + if (! $this->failWhenEmpty && count($this->responses) === 0) { + return value($this->emptyResponse ?? Factory::response()); + } + + return array_shift($this->responses); + } +} diff --git a/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php b/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php index 25d6ec1e99863124b5a058f7ea774db40085f150..0d5f62fc753258ebf996052cbf60aafb05331f7d 100644 --- a/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php +++ b/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php @@ -6,24 +6,6 @@ use Illuminate\Support\Str; trait InteractsWithContentTypes { - /** - * Determine if the given content types match. - * - * @param string $actual - * @param string $type - * @return bool - */ - public static function matchesType($actual, $type) - { - if ($actual === $type) { - return true; - } - - $split = explode('/', $actual); - - return isset($split[1]) && preg_match('#'.preg_quote($split[0], '#').'/.+\+'.preg_quote($split[1], '#').'#', $type); - } - /** * Determine if the request is sending JSON. * @@ -53,7 +35,7 @@ trait InteractsWithContentTypes { $acceptable = $this->getAcceptableContentTypes(); - return isset($acceptable[0]) && Str::contains($acceptable[0], ['/json', '+json']); + return isset($acceptable[0]) && Str::contains(strtolower($acceptable[0]), ['/json', '+json']); } /** @@ -78,6 +60,10 @@ trait InteractsWithContentTypes } foreach ($types as $type) { + $accept = strtolower($accept); + + $type = strtolower($type); + if ($this->matchesType($accept, $type) || $accept === strtok($type, '/').'/*') { return true; } @@ -111,6 +97,10 @@ trait InteractsWithContentTypes $type = $mimeType; } + $accept = strtolower($accept); + + $type = strtolower($type); + if ($this->matchesType($type, $accept) || $accept === strtok($type, '/').'/*') { return $contentType; } @@ -152,6 +142,24 @@ trait InteractsWithContentTypes return $this->accepts('text/html'); } + /** + * Determine if the given content types match. + * + * @param string $actual + * @param string $type + * @return bool + */ + public static function matchesType($actual, $type) + { + if ($actual === $type) { + return true; + } + + $split = explode('/', $actual); + + return isset($split[1]) && preg_match('#'.preg_quote($split[0], '#').'/.+\+'.preg_quote($split[1], '#').'#', $type); + } + /** * Get the data format expected in the response. * diff --git a/src/Illuminate/Http/Concerns/InteractsWithFlashData.php b/src/Illuminate/Http/Concerns/InteractsWithFlashData.php index 25e11a95438f7d47128240679ae696c5b16048e4..6682e5427273efccd070a777f351f8cd3f714461 100644 --- a/src/Illuminate/Http/Concerns/InteractsWithFlashData.php +++ b/src/Illuminate/Http/Concerns/InteractsWithFlashData.php @@ -9,7 +9,7 @@ trait InteractsWithFlashData * * @param string|null $key * @param string|array|null $default - * @return string|array + * @return string|array|null */ public function old($key = null, $default = null) { diff --git a/src/Illuminate/Http/Concerns/InteractsWithInput.php b/src/Illuminate/Http/Concerns/InteractsWithInput.php index 12025eb0d08d5d861db34108974d30b7b51a744d..ae8b6fe738809b4d71f5588ad820abec10822f24 100644 --- a/src/Illuminate/Http/Concerns/InteractsWithInput.php +++ b/src/Illuminate/Http/Concerns/InteractsWithInput.php @@ -4,9 +4,10 @@ namespace Illuminate\Http\Concerns; use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; -use Illuminate\Support\Str; +use Illuminate\Support\Facades\Date; use SplFileInfo; use stdClass; +use Symfony\Component\VarDumper\VarDumper; trait InteractsWithInput { @@ -54,8 +55,12 @@ trait InteractsWithInput { $header = $this->header('Authorization', ''); - if (Str::startsWith($header, 'Bearer ')) { - return Str::substr($header, 7); + $position = strrpos($header, 'Bearer '); + + if ($position !== false) { + $header = substr($header, $position + 7); + + return strpos($header, ',') !== false ? strstr($header, ',', true) : $header; } } @@ -106,6 +111,27 @@ trait InteractsWithInput return Arr::hasAny($input, $keys); } + /** + * Apply the callback if the request contains the given input item key. + * + * @param string $key + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function whenHas($key, callable $callback, callable $default = null) + { + if ($this->has($key)) { + return $callback(data_get($this->all(), $key)) ?: $this; + } + + if ($default) { + return $default(); + } + + return $this; + } + /** * Determine if the request contains a non-empty value for an input item. * @@ -125,6 +151,25 @@ trait InteractsWithInput return true; } + /** + * Determine if the request contains an empty value for an input item. + * + * @param string|array $key + * @return bool + */ + public function isNotFilled($key) + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $value) { + if (! $this->isEmptyString($value)) { + return false; + } + } + + return true; + } + /** * Determine if the request contains a non-empty value for any of the given inputs. * @@ -144,6 +189,27 @@ trait InteractsWithInput return false; } + /** + * Apply the callback if the request contains a non-empty value for the given input item key. + * + * @param string $key + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function whenFilled($key, callable $callback, callable $default = null) + { + if ($this->filled($key)) { + return $callback(data_get($this->all(), $key)) ?: $this; + } + + if ($default) { + return $default(); + } + + return $this; + } + /** * Determine if the request is missing a given input item key. * @@ -231,6 +297,38 @@ trait InteractsWithInput return filter_var($this->input($key, $default), FILTER_VALIDATE_BOOLEAN); } + /** + * Retrieve input from the request as a Carbon instance. + * + * @param string $key + * @param string|null $format + * @param string|null $tz + * @return \Illuminate\Support\Carbon|null + */ + public function date($key, $format = null, $tz = null) + { + if ($this->isNotFilled($key)) { + return null; + } + + if (is_null($format)) { + return Date::parse($this->input($key), $tz); + } + + return Date::createFromFormat($format, $this->input($key), $tz); + } + + /** + * Retrieve input from the request as a collection. + * + * @param array|string|null $key + * @return \Illuminate\Support\Collection + */ + public function collect($key = null) + { + return collect(is_array($key) ? $this->only($key) : $this->input($key)); + } + /** * Get a subset containing the provided keys with values from the input data. * @@ -411,4 +509,32 @@ trait InteractsWithInput return $this->$source->get($key, $default); } + + /** + * Dump the request items and end the script. + * + * @param mixed $keys + * @return void + */ + public function dd(...$keys) + { + $this->dump(...$keys); + + exit(1); + } + + /** + * Dump the items. + * + * @param mixed $keys + * @return $this + */ + public function dump($keys = []) + { + $keys = is_array($keys) ? $keys : func_get_args(); + + VarDumper::dump(count($keys) > 0 ? $this->only($keys) : $this->all()); + + return $this; + } } diff --git a/src/Illuminate/Http/Exceptions/PostTooLargeException.php b/src/Illuminate/Http/Exceptions/PostTooLargeException.php index 5c56e3bcaa0739273ba680535647dac9df9f76b7..75f6cdde313d8341dd1dd92b41568007657e3b52 100644 --- a/src/Illuminate/Http/Exceptions/PostTooLargeException.php +++ b/src/Illuminate/Http/Exceptions/PostTooLargeException.php @@ -2,8 +2,8 @@ namespace Illuminate\Http\Exceptions; -use Exception; use Symfony\Component\HttpKernel\Exception\HttpException; +use Throwable; class PostTooLargeException extends HttpException { @@ -11,12 +11,12 @@ class PostTooLargeException extends HttpException * Create a new "post too large" exception instance. * * @param string|null $message - * @param \Exception|null $previous + * @param \Throwable|null $previous * @param array $headers * @param int $code * @return void */ - public function __construct($message = null, Exception $previous = null, array $headers = [], $code = 0) + public function __construct($message = null, Throwable $previous = null, array $headers = [], $code = 0) { parent::__construct(413, $message, $previous, $headers, $code); } diff --git a/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php b/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php index 0ba337f6090d759b65a8447b6895f0714bdde079..c09393174d3a1430534ab830e134bf1be5fbfff0 100644 --- a/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php +++ b/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php @@ -2,8 +2,8 @@ namespace Illuminate\Http\Exceptions; -use Exception; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; +use Throwable; class ThrottleRequestsException extends TooManyRequestsHttpException { @@ -11,12 +11,12 @@ class ThrottleRequestsException extends TooManyRequestsHttpException * Create a new throttle requests exception instance. * * @param string|null $message - * @param \Exception|null $previous + * @param \Throwable|null $previous * @param array $headers * @param int $code * @return void */ - public function __construct($message = null, Exception $previous = null, array $headers = [], $code = 0) + public function __construct($message = null, Throwable $previous = null, array $headers = [], $code = 0) { parent::__construct(null, $message, $previous, $code, $headers); } diff --git a/src/Illuminate/Http/JsonResponse.php b/src/Illuminate/Http/JsonResponse.php index 9f87e6c31ce93b92c92f16663c8c05be1fae58e8..84a68f9714911676eb771eb59744935140b9aebd 100755 --- a/src/Illuminate/Http/JsonResponse.php +++ b/src/Illuminate/Http/JsonResponse.php @@ -22,13 +22,24 @@ class JsonResponse extends BaseJsonResponse * @param int $status * @param array $headers * @param int $options + * @param bool $json * @return void */ - public function __construct($data = null, $status = 200, $headers = [], $options = 0) + public function __construct($data = null, $status = 200, $headers = [], $options = 0, $json = false) { $this->encodingOptions = $options; - parent::__construct($data, $status, $headers); + parent::__construct($data, $status, $headers, $json); + } + + /** + * {@inheritdoc} + * + * @return static + */ + public static function fromJsonString(?string $data = null, int $status = 200, array $headers = []) + { + return new static($data, $status, $headers, 0, true); } /** @@ -56,11 +67,16 @@ class JsonResponse extends BaseJsonResponse /** * {@inheritdoc} + * + * @return static */ public function setData($data = []) { $this->original = $data; + // Ensure json_last_error() is cleared... + json_decode('[]'); + if ($data instanceof Jsonable) { $this->data = $data->toJson($this->encodingOptions); } elseif ($data instanceof JsonSerializable) { @@ -100,6 +116,8 @@ class JsonResponse extends BaseJsonResponse /** * {@inheritdoc} + * + * @return static */ public function setEncodingOptions($options) { diff --git a/src/Illuminate/Http/Middleware/SetCacheHeaders.php b/src/Illuminate/Http/Middleware/SetCacheHeaders.php index b6d964bc294b728a47b03b01b7e35904f0ec6c5d..b42dc2f2f822296063bae90647271bc9eead17bf 100644 --- a/src/Illuminate/Http/Middleware/SetCacheHeaders.php +++ b/src/Illuminate/Http/Middleware/SetCacheHeaders.php @@ -55,7 +55,7 @@ class SetCacheHeaders */ protected function parseOptions($options) { - return collect(explode(';', $options))->mapWithKeys(function ($option) { + return collect(explode(';', rtrim($options, ';')))->mapWithKeys(function ($option) { $data = explode('=', $option, 2); return [$data[0] => $data[1] ?? true]; diff --git a/src/Illuminate/Http/Middleware/TrustProxies.php b/src/Illuminate/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000000000000000000000000000000000000..872fd3ca2e715ba0076c3e046c4518491aa75cec --- /dev/null +++ b/src/Illuminate/Http/Middleware/TrustProxies.php @@ -0,0 +1,136 @@ +<?php + +namespace Illuminate\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; + +class TrustProxies +{ + /** + * The trusted proxies for the application. + * + * @var array|string|null + */ + protected $proxies; + + /** + * The proxy header mappings. + * + * @var int + */ + protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function handle(Request $request, Closure $next) + { + $request::setTrustedProxies([], $this->getTrustedHeaderNames()); + + $this->setTrustedProxyIpAddresses($request); + + return $next($request); + } + + /** + * Sets the trusted proxies on the request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function setTrustedProxyIpAddresses(Request $request) + { + $trustedIps = $this->proxies() ?: config('trustedproxy.proxies'); + + if ($trustedIps === '*' || $trustedIps === '**') { + return $this->setTrustedProxyIpAddressesToTheCallingIp($request); + } + + $trustedIps = is_string($trustedIps) + ? array_map('trim', explode(',', $trustedIps)) + : $trustedIps; + + if (is_array($trustedIps)) { + return $this->setTrustedProxyIpAddressesToSpecificIps($request, $trustedIps); + } + } + + /** + * Specify the IP addresses to trust explicitly. + * + * @param \Illuminate\Http\Request $request + * @param array $trustedIps + * @return void + */ + protected function setTrustedProxyIpAddressesToSpecificIps(Request $request, array $trustedIps) + { + $request->setTrustedProxies($trustedIps, $this->getTrustedHeaderNames()); + } + + /** + * Set the trusted proxy to be the IP address calling this servers. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function setTrustedProxyIpAddressesToTheCallingIp(Request $request) + { + $request->setTrustedProxies([$request->server->get('REMOTE_ADDR')], $this->getTrustedHeaderNames()); + } + + /** + * Retrieve trusted header name(s), falling back to defaults if config not set. + * + * @return int A bit field of Request::HEADER_*, to set which headers to trust from your proxies. + */ + protected function getTrustedHeaderNames() + { + switch ($this->headers) { + case 'HEADER_X_FORWARDED_AWS_ELB': + case Request::HEADER_X_FORWARDED_AWS_ELB: + return Request::HEADER_X_FORWARDED_AWS_ELB; + + case 'HEADER_FORWARDED': + case Request::HEADER_FORWARDED: + return Request::HEADER_FORWARDED; + + case 'HEADER_X_FORWARDED_FOR': + case Request::HEADER_X_FORWARDED_FOR: + return Request::HEADER_X_FORWARDED_FOR; + + case 'HEADER_X_FORWARDED_HOST': + case Request::HEADER_X_FORWARDED_HOST: + return Request::HEADER_X_FORWARDED_HOST; + + case 'HEADER_X_FORWARDED_PORT': + case Request::HEADER_X_FORWARDED_PORT: + return Request::HEADER_X_FORWARDED_PORT; + + case 'HEADER_X_FORWARDED_PROTO': + case Request::HEADER_X_FORWARDED_PROTO: + return Request::HEADER_X_FORWARDED_PROTO; + + default: + return Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + } + + return $this->headers; + } + + /** + * Get the trusted proxies. + * + * @return array|string|null + */ + protected function proxies() + { + return $this->proxies; + } +} diff --git a/src/Illuminate/Http/RedirectResponse.php b/src/Illuminate/Http/RedirectResponse.php index 874dda49b4998c8d2e73c13cdf2e79c23acbf02f..32bb5fcffb950ed59070f839d04726a256af8218 100755 --- a/src/Illuminate/Http/RedirectResponse.php +++ b/src/Illuminate/Http/RedirectResponse.php @@ -160,6 +160,28 @@ class RedirectResponse extends BaseRedirectResponse return new MessageBag((array) $provider); } + /** + * Add a fragment identifier to the URL. + * + * @param string $fragment + * @return $this + */ + public function withFragment($fragment) + { + return $this->withoutFragment() + ->setTargetUrl($this->getTargetUrl().'#'.Str::after($fragment, '#')); + } + + /** + * Remove any fragment identifier from the response URL. + * + * @return $this + */ + public function withoutFragment() + { + return $this->setTargetUrl(Str::before($this->getTargetUrl(), '#')); + } + /** * Get the original response content. * diff --git a/src/Illuminate/Http/Request.php b/src/Illuminate/Http/Request.php index a4018c5c21744a1413684b5ac89f2c375d67022d..79175ac4476e530f72fae2d681bcfc9b55f33fcb 100644 --- a/src/Illuminate/Http/Request.php +++ b/src/Illuminate/Http/Request.php @@ -133,6 +133,23 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess : $this->fullUrl().$question.Arr::query($query); } + /** + * Get the full URL for the request without the given query string parameters. + * + * @param array|string $query + * @return string + */ + public function fullUrlWithoutQuery($keys) + { + $query = Arr::except($this->query(), $keys); + + $question = $this->getBaseUrl().$this->getPathInfo() === '/' ? '/?' : '?'; + + return count($query) > 0 + ? $this->url().$question.Arr::query($query) + : $this->url(); + } + /** * Get the current path info for the request. * @@ -142,7 +159,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess { $pattern = trim($this->getPathInfo(), '/'); - return $pattern == '' ? '/' : $pattern; + return $pattern === '' ? '/' : $pattern; } /** @@ -212,7 +229,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess } /** - * Determine if the current request URL and query string matches a pattern. + * Determine if the current request URL and query string match a pattern. * * @param mixed ...$patterns * @return bool @@ -241,7 +258,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess } /** - * Determine if the request is the result of an PJAX call. + * Determine if the request is the result of a PJAX call. * * @return bool */ @@ -251,14 +268,14 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess } /** - * Determine if the request is the result of an prefetch call. + * Determine if the request is the result of a prefetch call. * * @return bool */ public function prefetch() { - return strcasecmp($this->server->get('HTTP_X_MOZ'), 'prefetch') === 0 || - strcasecmp($this->headers->get('Purpose'), 'prefetch') === 0; + return strcasecmp($this->server->get('HTTP_X_MOZ') ?? '', 'prefetch') === 0 || + strcasecmp($this->headers->get('Purpose') ?? '', 'prefetch') === 0; } /** @@ -314,6 +331,19 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess return $this; } + /** + * Merge new input into the request's input, but only when that key is missing from the request. + * + * @param array $input + * @return $this + */ + public function mergeIfMissing(array $input) + { + return $this->merge(collect($input)->filter(function ($value, $key) { + return $this->missing($key); + })->toArray()); + } + /** * Replace the input for the current request. * @@ -336,7 +366,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess * @param mixed $default * @return mixed */ - public function get($key, $default = null) + public function get(string $key, $default = null) { return parent::get($key, $default); } @@ -423,10 +453,6 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess */ public static function createFromBase(SymfonyRequest $request) { - if ($request instanceof static) { - return $request; - } - $newRequest = (new static)->duplicate( $request->query->all(), $request->request->all(), $request->attributes->all(), $request->cookies->all(), $request->files->all(), $request->server->all() @@ -443,6 +469,8 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess /** * {@inheritdoc} + * + * @return static */ public function duplicate(array $query = null, array $request = null, array $attributes = null, array $cookies = null, array $files = null, array $server = null) { @@ -638,10 +666,13 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess * @param string $offset * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { + $route = $this->route(); + return Arr::has( - $this->all() + $this->route()->parameters(), + $this->all() + ($route ? $route->parameters() : []), $offset ); } @@ -652,6 +683,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess * @param string $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->__get($offset); @@ -664,6 +696,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->getInputSource()->set($offset, $value); @@ -675,6 +708,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess * @param string $offset * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { $this->getInputSource()->remove($offset); diff --git a/src/Illuminate/Http/Resources/CollectsResources.php b/src/Illuminate/Http/Resources/CollectsResources.php index a5531f7a02ce741034520fb51e78420195e3e4cf..a4d4faba27130d1045584e9742f2e412feb79ba1 100644 --- a/src/Illuminate/Http/Resources/CollectsResources.php +++ b/src/Illuminate/Http/Resources/CollectsResources.php @@ -2,9 +2,11 @@ namespace Illuminate\Http\Resources; +use Illuminate\Pagination\AbstractCursorPaginator; use Illuminate\Pagination\AbstractPaginator; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use ReflectionClass; trait CollectsResources { @@ -30,7 +32,7 @@ trait CollectsResources ? $resource->mapInto($collects) : $resource->toBase(); - return $resource instanceof AbstractPaginator + return ($resource instanceof AbstractPaginator || $resource instanceof AbstractCursorPaginator) ? $resource->setCollection($this->collection) : $this->collection; } @@ -47,16 +49,36 @@ trait CollectsResources } if (Str::endsWith(class_basename($this), 'Collection') && - class_exists($class = Str::replaceLast('Collection', '', get_class($this)))) { + (class_exists($class = Str::replaceLast('Collection', '', get_class($this))) || + class_exists($class = Str::replaceLast('Collection', 'Resource', get_class($this))))) { return $class; } } + /** + * Get the JSON serialization options that should be applied to the resource response. + * + * @return int + */ + public function jsonOptions() + { + $collects = $this->collects(); + + if (! $collects) { + return 0; + } + + return (new ReflectionClass($collects)) + ->newInstanceWithoutConstructor() + ->jsonOptions(); + } + /** * Get an iterator for the resource collection. * * @return \ArrayIterator */ + #[\ReturnTypeWillChange] public function getIterator() { return $this->collection->getIterator(); diff --git a/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php b/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php index 0f6e1889910446cf35f59509d3f30e9d46bae7a2..5b8c8d082f1b847c7c70275ecbb572ff47b4b462 100644 --- a/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php +++ b/src/Illuminate/Http/Resources/ConditionallyLoadsAttributes.php @@ -144,6 +144,23 @@ trait ConditionallyLoadsAttributes ); } + /** + * Retrieve an accessor when it has been appended. + * + * @param string $attribute + * @param mixed $value + * @param mixed $default + * @return \Illuminate\Http\Resources\MissingValue|mixed + */ + protected function whenAppended($attribute, $value = null, $default = null) + { + if ($this->resource->hasAppended($attribute)) { + return func_num_args() >= 2 ? value($value) : $this->resource->$attribute; + } + + return func_num_args() === 3 ? value($default) : new MissingValue; + } + /** * Retrieve a relationship if it has been loaded. * diff --git a/src/Illuminate/Http/Resources/DelegatesToResource.php b/src/Illuminate/Http/Resources/DelegatesToResource.php index 036a143100e53b00bd93a9adf29761a780c7e902..48f455f97e025647ce7d18b2ae9d7b3d41503d58 100644 --- a/src/Illuminate/Http/Resources/DelegatesToResource.php +++ b/src/Illuminate/Http/Resources/DelegatesToResource.php @@ -33,11 +33,27 @@ trait DelegatesToResource * Retrieve the model for a bound value. * * @param mixed $value + * @param string|null $field * @return void * * @throws \Exception */ - public function resolveRouteBinding($value) + public function resolveRouteBinding($value, $field = null) + { + throw new Exception('Resources may not be implicitly resolved from route bindings.'); + } + + /** + * Retrieve the model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return void + * + * @throws \Exception + */ + public function resolveChildRouteBinding($childType, $value, $field = null) { throw new Exception('Resources may not be implicitly resolved from route bindings.'); } @@ -48,6 +64,7 @@ trait DelegatesToResource * @param mixed $offset * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return isset($this->resource[$offset]); @@ -59,6 +76,7 @@ trait DelegatesToResource * @param mixed $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->resource[$offset]; @@ -71,6 +89,7 @@ trait DelegatesToResource * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->resource[$offset] = $value; @@ -82,6 +101,7 @@ trait DelegatesToResource * @param mixed $offset * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->resource[$offset]); diff --git a/src/Illuminate/Http/Resources/Json/JsonResource.php b/src/Illuminate/Http/Resources/Json/JsonResource.php index 42d8fb2a4a0d453c6d9d7962aa7a96f0c7486219..8c8bf000bd43ebb8e9f135fefcefb29b0cc7a1b9 100644 --- a/src/Illuminate/Http/Resources/Json/JsonResource.php +++ b/src/Illuminate/Http/Resources/Json/JsonResource.php @@ -7,6 +7,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Responsable; +use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Http\Resources\ConditionallyLoadsAttributes; use Illuminate\Http\Resources\DelegatesToResource; use JsonSerializable; @@ -41,7 +42,7 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou /** * The "data" wrapper that should be applied. * - * @var string + * @var string|null */ public static $wrap = 'data'; @@ -68,7 +69,7 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou } /** - * Create new anonymous resource collection. + * Create a new anonymous resource collection. * * @param mixed $resource * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection @@ -107,7 +108,7 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou * Transform the resource into an array. * * @param \Illuminate\Http\Request $request - * @return array + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { @@ -120,6 +121,25 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou : $this->resource->toArray(); } + /** + * Convert the model instance to JSON. + * + * @param int $options + * @return string + * + * @throws \Illuminate\Database\Eloquent\JsonEncodingException + */ + public function toJson($options = 0) + { + $json = json_encode($this->jsonSerialize(), $options); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw JsonEncodingException::forResource($this, json_last_error_msg()); + } + + return $json; + } + /** * Get any additional data that should be returned with the resource array. * @@ -144,6 +164,16 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou return $this; } + /** + * Get the JSON serialization options that should be applied to the resource response. + * + * @return int + */ + public function jsonOptions() + { + return 0; + } + /** * Customize the response for a request. * @@ -206,6 +236,7 @@ class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRou * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->resolve(Container::getInstance()->make('request')); diff --git a/src/Illuminate/Http/Resources/Json/PaginatedResourceResponse.php b/src/Illuminate/Http/Resources/Json/PaginatedResourceResponse.php index b99ea9625202e292f205b7529d922a5fd2837daa..bd3e8f9ade364327c7b25fd8ee6eb73c434c9b60 100644 --- a/src/Illuminate/Http/Resources/Json/PaginatedResourceResponse.php +++ b/src/Illuminate/Http/Resources/Json/PaginatedResourceResponse.php @@ -23,10 +23,12 @@ class PaginatedResourceResponse extends ResourceResponse $this->resource->additional ) ), - $this->calculateStatus() + $this->calculateStatus(), + [], + $this->resource->jsonOptions() ), function ($response) use ($request) { $response->original = $this->resource->resource->map(function ($item) { - return $item->resource; + return is_array($item) ? Arr::get($item, 'resource') : $item->resource; }); $this->resource->withResponse($request, $response); @@ -43,10 +45,16 @@ class PaginatedResourceResponse extends ResourceResponse { $paginated = $this->resource->resource->toArray(); - return [ + $default = [ 'links' => $this->paginationLinks($paginated), 'meta' => $this->meta($paginated), ]; + + if (method_exists($this->resource, 'paginationInformation')) { + return $this->resource->paginationInformation($request, $paginated, $default); + } + + return $default; } /** diff --git a/src/Illuminate/Http/Resources/Json/Resource.php b/src/Illuminate/Http/Resources/Json/Resource.php deleted file mode 100644 index 49f674415713c80e255d97d8b0b47cc930a1231c..0000000000000000000000000000000000000000 --- a/src/Illuminate/Http/Resources/Json/Resource.php +++ /dev/null @@ -1,8 +0,0 @@ -<?php - -namespace Illuminate\Http\Resources\Json; - -class Resource extends JsonResource -{ - // -} diff --git a/src/Illuminate/Http/Resources/Json/ResourceCollection.php b/src/Illuminate/Http/Resources/Json/ResourceCollection.php index 2931fd6463c74b0415d79859fe1dcb1d5bbd53a2..65710aa327003261e034721ec3783b8429cdf2c7 100644 --- a/src/Illuminate/Http/Resources/Json/ResourceCollection.php +++ b/src/Illuminate/Http/Resources/Json/ResourceCollection.php @@ -4,6 +4,7 @@ namespace Illuminate\Http\Resources\Json; use Countable; use Illuminate\Http\Resources\CollectsResources; +use Illuminate\Pagination\AbstractCursorPaginator; use Illuminate\Pagination\AbstractPaginator; use IteratorAggregate; @@ -84,6 +85,7 @@ class ResourceCollection extends JsonResource implements Countable, IteratorAggr * * @return int */ + #[\ReturnTypeWillChange] public function count() { return $this->collection->count(); @@ -93,7 +95,7 @@ class ResourceCollection extends JsonResource implements Countable, IteratorAggr * Transform the resource into a JSON array. * * @param \Illuminate\Http\Request $request - * @return array + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { @@ -108,7 +110,7 @@ class ResourceCollection extends JsonResource implements Countable, IteratorAggr */ public function toResponse($request) { - if ($this->resource instanceof AbstractPaginator) { + if ($this->resource instanceof AbstractPaginator || $this->resource instanceof AbstractCursorPaginator) { return $this->preparePaginatedResponse($request); } diff --git a/src/Illuminate/Http/Resources/Json/ResourceResponse.php b/src/Illuminate/Http/Resources/Json/ResourceResponse.php index 2e9d326d5c3eba83d0aa22f3e2b8e6a130050d27..51f36576f0ae9e8fcb9e250cf4b79bb63547dff2 100644 --- a/src/Illuminate/Http/Resources/Json/ResourceResponse.php +++ b/src/Illuminate/Http/Resources/Json/ResourceResponse.php @@ -40,7 +40,9 @@ class ResourceResponse implements Responsable $this->resource->with($request), $this->resource->additional ), - $this->calculateStatus() + $this->calculateStatus(), + [], + $this->resource->jsonOptions() ), function ($response) use ($request) { $response->original = $this->resource->resource; diff --git a/src/Illuminate/Http/Resources/MergeValue.php b/src/Illuminate/Http/Resources/MergeValue.php index ee557e8f3b8782f98fa28ffd306808abe0cd45c7..fb6880fb725c4460316e57c988f15fd10158ec7f 100644 --- a/src/Illuminate/Http/Resources/MergeValue.php +++ b/src/Illuminate/Http/Resources/MergeValue.php @@ -15,7 +15,7 @@ class MergeValue public $data; /** - * Create new merge value instance. + * Create a new merge value instance. * * @param \Illuminate\Support\Collection|\JsonSerializable|array $data * @return void diff --git a/src/Illuminate/Http/Response.php b/src/Illuminate/Http/Response.php index 81208acac491d6acdec97a4b3b4d493bef08be6b..8599a8e53a5d1ee93ef6988a2338218754378854 100755 --- a/src/Illuminate/Http/Response.php +++ b/src/Illuminate/Http/Response.php @@ -7,20 +7,43 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Contracts\Support\Renderable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use JsonSerializable; -use Symfony\Component\HttpFoundation\Response as BaseResponse; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; -class Response extends BaseResponse +class Response extends SymfonyResponse { use ResponseTrait, Macroable { Macroable::__call as macroCall; } + /** + * Create a new HTTP response. + * + * @param mixed $content + * @param int $status + * @param array $headers + * @return void + * + * @throws \InvalidArgumentException + */ + public function __construct($content = '', $status = 200, array $headers = []) + { + $this->headers = new ResponseHeaderBag($headers); + + $this->setContent($content); + $this->setStatusCode($status); + $this->setProtocolVersion('1.0'); + } + /** * Set the content on the response. * * @param mixed $content * @return $this + * + * @throws \InvalidArgumentException */ public function setContent($content) { @@ -33,6 +56,10 @@ class Response extends BaseResponse $this->header('Content-Type', 'application/json'); $content = $this->morphToJson($content); + + if ($content === false) { + throw new InvalidArgumentException(json_last_error_msg()); + } } // If this content implements the "Renderable" interface then we will call the diff --git a/src/Illuminate/Http/ResponseTrait.php b/src/Illuminate/Http/ResponseTrait.php index 0858b40e24949b0400a6ee9448fd5be65166dff6..cbe29dcc99029f91fb3e30d915031213094e59c6 100644 --- a/src/Illuminate/Http/ResponseTrait.php +++ b/src/Illuminate/Http/ResponseTrait.php @@ -2,9 +2,9 @@ namespace Illuminate\Http; -use Exception; use Illuminate\Http\Exceptions\HttpResponseException; use Symfony\Component\HttpFoundation\HeaderBag; +use Throwable; trait ResponseTrait { @@ -18,7 +18,7 @@ trait ResponseTrait /** * The exception that triggered the error response (if applicable). * - * @var \Exception|null + * @var \Throwable|null */ public $exception; @@ -32,6 +32,16 @@ trait ResponseTrait return $this->getStatusCode(); } + /** + * Get the status text for the response. + * + * @return string + */ + public function statusText() + { + return $this->statusText; + } + /** * Get the content of the response. * @@ -116,6 +126,25 @@ trait ResponseTrait return $this; } + /** + * Expire a cookie when sending the response. + * + * @param \Symfony\Component\HttpFoundation\Cookie|mixed $cookie + * @param string|null $path + * @param string|null $domain + * @return $this + */ + public function withoutCookie($cookie, $path = null, $domain = null) + { + if (is_string($cookie) && function_exists('cookie')) { + $cookie = cookie($cookie, null, -2628000, $path, $domain); + } + + $this->headers->setCookie($cookie); + + return $this; + } + /** * Get the callback of the response. * @@ -129,10 +158,10 @@ trait ResponseTrait /** * Set the exception to attach to the response. * - * @param \Exception $e + * @param \Throwable $e * @return $this */ - public function withException(Exception $e) + public function withException(Throwable $e) { $this->exception = $e; diff --git a/src/Illuminate/Http/Testing/File.php b/src/Illuminate/Http/Testing/File.php index c15282686b8a28a3776af27ac425d69df90b4b5c..c714529feb6c3080b5de2c9c7471c29519c47bc3 100644 --- a/src/Illuminate/Http/Testing/File.php +++ b/src/Illuminate/Http/Testing/File.php @@ -107,6 +107,7 @@ class File extends UploadedFile * * @return int */ + #[\ReturnTypeWillChange] public function getSize() { return $this->sizeToReport ?: parent::getSize(); diff --git a/src/Illuminate/Http/Testing/FileFactory.php b/src/Illuminate/Http/Testing/FileFactory.php index 5b729ee1eae5135a222761473fb2495cc02dc039..9e25d72de8f3b26ec1118dd4e44d6786132e68d4 100644 --- a/src/Illuminate/Http/Testing/FileFactory.php +++ b/src/Illuminate/Http/Testing/FileFactory.php @@ -2,8 +2,6 @@ namespace Illuminate\Http\Testing; -use Illuminate\Support\Str; - class FileFactory { /** @@ -55,7 +53,7 @@ class FileFactory public function image($name, $width = 10, $height = 10) { return new File($name, $this->generateImage( - $width, $height, Str::endsWith(Str::lower($name), ['.jpg', '.jpeg']) ? 'jpeg' : 'png' + $width, $height, pathinfo($name, PATHINFO_EXTENSION) )); } @@ -64,24 +62,21 @@ class FileFactory * * @param int $width * @param int $height - * @param string $type + * @param string $extension * @return resource */ - protected function generateImage($width, $height, $type) + protected function generateImage($width, $height, $extension) { - return tap(tmpfile(), function ($temp) use ($width, $height, $type) { + return tap(tmpfile(), function ($temp) use ($width, $height, $extension) { ob_start(); + $extension = in_array($extension, ['jpeg', 'png', 'gif', 'webp', 'wbmp', 'bmp']) + ? strtolower($extension) + : 'jpeg'; + $image = imagecreatetruecolor($width, $height); - switch ($type) { - case 'jpeg': - imagejpeg($image); - break; - case 'png': - imagepng($image); - break; - } + call_user_func("image{$extension}", $image); fwrite($temp, ob_get_clean()); }); diff --git a/src/Illuminate/Http/Testing/MimeType.php b/src/Illuminate/Http/Testing/MimeType.php index af1fc602cf23f466af13336313e2b114fcff7fd5..d188a4be35e8a8f8cce48b388aa41240bd105035 100644 --- a/src/Illuminate/Http/Testing/MimeType.php +++ b/src/Illuminate/Http/Testing/MimeType.php @@ -2,782 +2,31 @@ namespace Illuminate\Http\Testing; +use Illuminate\Support\Arr; +use Symfony\Component\Mime\MimeTypes; + class MimeType { /** - * An array of extension to MIME types. + * The mime types instance. * - * @var array + * @var \Symfony\Component\Mime\MimeTypes|null */ - protected static $mimes = [ - 'ez' => 'application/andrew-inset', - 'aw' => 'application/applixware', - 'atom' => 'application/atom+xml', - 'atomcat' => 'application/atomcat+xml', - 'atomsvc' => 'application/atomsvc+xml', - 'ccxml' => 'application/ccxml+xml', - 'cdmia' => 'application/cdmi-capability', - 'cdmic' => 'application/cdmi-container', - 'cdmid' => 'application/cdmi-domain', - 'cdmio' => 'application/cdmi-object', - 'cdmiq' => 'application/cdmi-queue', - 'cu' => 'application/cu-seeme', - 'davmount' => 'application/davmount+xml', - 'dbk' => 'application/docbook+xml', - 'dssc' => 'application/dssc+der', - 'xdssc' => 'application/dssc+xml', - 'ecma' => 'application/ecmascript', - 'emma' => 'application/emma+xml', - 'epub' => 'application/epub+zip', - 'exi' => 'application/exi', - 'pfr' => 'application/font-tdpfr', - 'gml' => 'application/gml+xml', - 'gpx' => 'application/gpx+xml', - 'gxf' => 'application/gxf', - 'stk' => 'application/hyperstudio', - 'ink' => 'application/inkml+xml', - 'ipfix' => 'application/ipfix', - 'jar' => 'application/java-archive', - 'ser' => 'application/java-serialized-object', - 'class' => 'application/java-vm', - 'js' => 'application/javascript', - 'json' => 'application/json', - 'jsonml' => 'application/jsonml+json', - 'lostxml' => 'application/lost+xml', - 'hqx' => 'application/mac-binhex40', - 'cpt' => 'application/mac-compactpro', - 'mads' => 'application/mads+xml', - 'mrc' => 'application/marc', - 'mrcx' => 'application/marcxml+xml', - 'ma' => 'application/mathematica', - 'mathml' => 'application/mathml+xml', - 'mbox' => 'application/mbox', - 'mscml' => 'application/mediaservercontrol+xml', - 'metalink' => 'application/metalink+xml', - 'meta4' => 'application/metalink4+xml', - 'mets' => 'application/mets+xml', - 'mods' => 'application/mods+xml', - 'm21' => 'application/mp21', - 'mp4s' => 'application/mp4', - 'doc' => 'application/msword', - 'mxf' => 'application/mxf', - 'bin' => 'application/octet-stream', - 'oda' => 'application/oda', - 'opf' => 'application/oebps-package+xml', - 'ogx' => 'application/ogg', - 'omdoc' => 'application/omdoc+xml', - 'onetoc' => 'application/onenote', - 'oxps' => 'application/oxps', - 'xer' => 'application/patch-ops-error+xml', - 'pdf' => 'application/pdf', - 'pgp' => 'application/pgp-encrypted', - 'asc' => 'application/pgp-signature', - 'prf' => 'application/pics-rules', - 'p10' => 'application/pkcs10', - 'p7m' => 'application/pkcs7-mime', - 'p7s' => 'application/pkcs7-signature', - 'p8' => 'application/pkcs8', - 'ac' => 'application/pkix-attr-cert', - 'cer' => 'application/pkix-cert', - 'crl' => 'application/pkix-crl', - 'pkipath' => 'application/pkix-pkipath', - 'pki' => 'application/pkixcmp', - 'pls' => 'application/pls+xml', - 'ai' => 'application/postscript', - 'cww' => 'application/prs.cww', - 'pskcxml' => 'application/pskc+xml', - 'rdf' => 'application/rdf+xml', - 'rif' => 'application/reginfo+xml', - 'rnc' => 'application/relax-ng-compact-syntax', - 'rl' => 'application/resource-lists+xml', - 'rld' => 'application/resource-lists-diff+xml', - 'rs' => 'application/rls-services+xml', - 'gbr' => 'application/rpki-ghostbusters', - 'mft' => 'application/rpki-manifest', - 'roa' => 'application/rpki-roa', - 'rsd' => 'application/rsd+xml', - 'rss' => 'application/rss+xml', - 'sbml' => 'application/sbml+xml', - 'scq' => 'application/scvp-cv-request', - 'scs' => 'application/scvp-cv-response', - 'spq' => 'application/scvp-vp-request', - 'spp' => 'application/scvp-vp-response', - 'sdp' => 'application/sdp', - 'setpay' => 'application/set-payment-initiation', - 'setreg' => 'application/set-registration-initiation', - 'shf' => 'application/shf+xml', - 'smi' => 'application/smil+xml', - 'rq' => 'application/sparql-query', - 'srx' => 'application/sparql-results+xml', - 'gram' => 'application/srgs', - 'grxml' => 'application/srgs+xml', - 'sru' => 'application/sru+xml', - 'ssdl' => 'application/ssdl+xml', - 'ssml' => 'application/ssml+xml', - 'tei' => 'application/tei+xml', - 'tfi' => 'application/thraud+xml', - 'tsd' => 'application/timestamped-data', - 'plb' => 'application/vnd.3gpp.pic-bw-large', - 'psb' => 'application/vnd.3gpp.pic-bw-small', - 'pvb' => 'application/vnd.3gpp.pic-bw-var', - 'tcap' => 'application/vnd.3gpp2.tcap', - 'pwn' => 'application/vnd.3m.post-it-notes', - 'aso' => 'application/vnd.accpac.simply.aso', - 'imp' => 'application/vnd.accpac.simply.imp', - 'acu' => 'application/vnd.acucobol', - 'atc' => 'application/vnd.acucorp', - 'air' => 'application/vnd.adobe.air-application-installer-package+zip', - 'fcdt' => 'application/vnd.adobe.formscentral.fcdt', - 'fxp' => 'application/vnd.adobe.fxp', - 'xdp' => 'application/vnd.adobe.xdp+xml', - 'xfdf' => 'application/vnd.adobe.xfdf', - 'ahead' => 'application/vnd.ahead.space', - 'azf' => 'application/vnd.airzip.filesecure.azf', - 'azs' => 'application/vnd.airzip.filesecure.azs', - 'azw' => 'application/vnd.amazon.ebook', - 'acc' => 'application/vnd.americandynamics.acc', - 'ami' => 'application/vnd.amiga.ami', - 'apk' => 'application/vnd.android.package-archive', - 'cii' => 'application/vnd.anser-web-certificate-issue-initiation', - 'fti' => 'application/vnd.anser-web-funds-transfer-initiation', - 'atx' => 'application/vnd.antix.game-component', - 'mpkg' => 'application/vnd.apple.installer+xml', - 'm3u8' => 'application/vnd.apple.mpegurl', - 'swi' => 'application/vnd.aristanetworks.swi', - 'iota' => 'application/vnd.astraea-software.iota', - 'aep' => 'application/vnd.audiograph', - 'mpm' => 'application/vnd.blueice.multipass', - 'bmi' => 'application/vnd.bmi', - 'rep' => 'application/vnd.businessobjects', - 'cdxml' => 'application/vnd.chemdraw+xml', - 'mmd' => 'application/vnd.chipnuts.karaoke-mmd', - 'cdy' => 'application/vnd.cinderella', - 'cla' => 'application/vnd.claymore', - 'rp9' => 'application/vnd.cloanto.rp9', - 'c4g' => 'application/vnd.clonk.c4group', - 'c11amc' => 'application/vnd.cluetrust.cartomobile-config', - 'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg', - 'csp' => 'application/vnd.commonspace', - 'cdbcmsg' => 'application/vnd.contact.cmsg', - 'cmc' => 'application/vnd.cosmocaller', - 'clkx' => 'application/vnd.crick.clicker', - 'clkk' => 'application/vnd.crick.clicker.keyboard', - 'clkp' => 'application/vnd.crick.clicker.palette', - 'clkt' => 'application/vnd.crick.clicker.template', - 'clkw' => 'application/vnd.crick.clicker.wordbank', - 'wbs' => 'application/vnd.criticaltools.wbs+xml', - 'pml' => 'application/vnd.ctc-posml', - 'ppd' => 'application/vnd.cups-ppd', - 'car' => 'application/vnd.curl.car', - 'pcurl' => 'application/vnd.curl.pcurl', - 'dart' => 'application/vnd.dart', - 'rdz' => 'application/vnd.data-vision.rdz', - 'uvf' => 'application/vnd.dece.data', - 'uvt' => 'application/vnd.dece.ttml+xml', - 'uvx' => 'application/vnd.dece.unspecified', - 'uvz' => 'application/vnd.dece.zip', - 'fe_launch' => 'application/vnd.denovo.fcselayout-link', - 'dna' => 'application/vnd.dna', - 'mlp' => 'application/vnd.dolby.mlp', - 'dpg' => 'application/vnd.dpgraph', - 'dfac' => 'application/vnd.dreamfactory', - 'kpxx' => 'application/vnd.ds-keypoint', - 'ait' => 'application/vnd.dvb.ait', - 'svc' => 'application/vnd.dvb.service', - 'geo' => 'application/vnd.dynageo', - 'mag' => 'application/vnd.ecowin.chart', - 'nml' => 'application/vnd.enliven', - 'esf' => 'application/vnd.epson.esf', - 'msf' => 'application/vnd.epson.msf', - 'qam' => 'application/vnd.epson.quickanime', - 'slt' => 'application/vnd.epson.salt', - 'ssf' => 'application/vnd.epson.ssf', - 'es3' => 'application/vnd.eszigno3+xml', - 'ez2' => 'application/vnd.ezpix-album', - 'ez3' => 'application/vnd.ezpix-package', - 'fdf' => 'application/vnd.fdf', - 'mseed' => 'application/vnd.fdsn.mseed', - 'seed' => 'application/vnd.fdsn.seed', - 'gph' => 'application/vnd.flographit', - 'ftc' => 'application/vnd.fluxtime.clip', - 'fm' => 'application/vnd.framemaker', - 'fnc' => 'application/vnd.frogans.fnc', - 'ltf' => 'application/vnd.frogans.ltf', - 'fsc' => 'application/vnd.fsc.weblaunch', - 'oas' => 'application/vnd.fujitsu.oasys', - 'oa2' => 'application/vnd.fujitsu.oasys2', - 'oa3' => 'application/vnd.fujitsu.oasys3', - 'fg5' => 'application/vnd.fujitsu.oasysgp', - 'bh2' => 'application/vnd.fujitsu.oasysprs', - 'ddd' => 'application/vnd.fujixerox.ddd', - 'xdw' => 'application/vnd.fujixerox.docuworks', - 'xbd' => 'application/vnd.fujixerox.docuworks.binder', - 'fzs' => 'application/vnd.fuzzysheet', - 'txd' => 'application/vnd.genomatix.tuxedo', - 'ggb' => 'application/vnd.geogebra.file', - 'ggt' => 'application/vnd.geogebra.tool', - 'gex' => 'application/vnd.geometry-explorer', - 'gxt' => 'application/vnd.geonext', - 'g2w' => 'application/vnd.geoplan', - 'g3w' => 'application/vnd.geospace', - 'gmx' => 'application/vnd.gmx', - 'kml' => 'application/vnd.google-earth.kml+xml', - 'kmz' => 'application/vnd.google-earth.kmz', - 'gqf' => 'application/vnd.grafeq', - 'gac' => 'application/vnd.groove-account', - 'ghf' => 'application/vnd.groove-help', - 'gim' => 'application/vnd.groove-identity-message', - 'grv' => 'application/vnd.groove-injector', - 'gtm' => 'application/vnd.groove-tool-message', - 'tpl' => 'application/vnd.groove-tool-template', - 'vcg' => 'application/vnd.groove-vcard', - 'hal' => 'application/vnd.hal+xml', - 'zmm' => 'application/vnd.handheld-entertainment+xml', - 'hbci' => 'application/vnd.hbci', - 'les' => 'application/vnd.hhe.lesson-player', - 'hpgl' => 'application/vnd.hp-hpgl', - 'hpid' => 'application/vnd.hp-hpid', - 'hps' => 'application/vnd.hp-hps', - 'jlt' => 'application/vnd.hp-jlyt', - 'pcl' => 'application/vnd.hp-pcl', - 'pclxl' => 'application/vnd.hp-pclxl', - 'sfd-hdstx' => 'application/vnd.hydrostatix.sof-data', - 'mpy' => 'application/vnd.ibm.minipay', - 'afp' => 'application/vnd.ibm.modcap', - 'irm' => 'application/vnd.ibm.rights-management', - 'sc' => 'application/vnd.ibm.secure-container', - 'icc' => 'application/vnd.iccprofile', - 'igl' => 'application/vnd.igloader', - 'ivp' => 'application/vnd.immervision-ivp', - 'ivu' => 'application/vnd.immervision-ivu', - 'igm' => 'application/vnd.insors.igm', - 'xpw' => 'application/vnd.intercon.formnet', - 'i2g' => 'application/vnd.intergeo', - 'qbo' => 'application/vnd.intu.qbo', - 'qfx' => 'application/vnd.intu.qfx', - 'rcprofile' => 'application/vnd.ipunplugged.rcprofile', - 'irp' => 'application/vnd.irepository.package+xml', - 'xpr' => 'application/vnd.is-xpr', - 'fcs' => 'application/vnd.isac.fcs', - 'jam' => 'application/vnd.jam', - 'rms' => 'application/vnd.jcp.javame.midlet-rms', - 'jisp' => 'application/vnd.jisp', - 'joda' => 'application/vnd.joost.joda-archive', - 'ktz' => 'application/vnd.kahootz', - 'karbon' => 'application/vnd.kde.karbon', - 'chrt' => 'application/vnd.kde.kchart', - 'kfo' => 'application/vnd.kde.kformula', - 'flw' => 'application/vnd.kde.kivio', - 'kon' => 'application/vnd.kde.kontour', - 'kpr' => 'application/vnd.kde.kpresenter', - 'ksp' => 'application/vnd.kde.kspread', - 'kwd' => 'application/vnd.kde.kword', - 'htke' => 'application/vnd.kenameaapp', - 'kia' => 'application/vnd.kidspiration', - 'kne' => 'application/vnd.kinar', - 'skp' => 'application/vnd.koan', - 'sse' => 'application/vnd.kodak-descriptor', - 'lasxml' => 'application/vnd.las.las+xml', - 'lbd' => 'application/vnd.llamagraphics.life-balance.desktop', - 'lbe' => 'application/vnd.llamagraphics.life-balance.exchange+xml', - '123' => 'application/vnd.lotus-1-2-3', - 'apr' => 'application/vnd.lotus-approach', - 'pre' => 'application/vnd.lotus-freelance', - 'nsf' => 'application/vnd.lotus-notes', - 'org' => 'application/vnd.lotus-organizer', - 'scm' => 'application/vnd.lotus-screencam', - 'lwp' => 'application/vnd.lotus-wordpro', - 'portpkg' => 'application/vnd.macports.portpkg', - 'mcd' => 'application/vnd.mcd', - 'mc1' => 'application/vnd.medcalcdata', - 'cdkey' => 'application/vnd.mediastation.cdkey', - 'mwf' => 'application/vnd.mfer', - 'mfm' => 'application/vnd.mfmp', - 'flo' => 'application/vnd.micrografx.flo', - 'igx' => 'application/vnd.micrografx.igx', - 'mif' => 'application/vnd.mif', - 'daf' => 'application/vnd.mobius.daf', - 'dis' => 'application/vnd.mobius.dis', - 'mbk' => 'application/vnd.mobius.mbk', - 'mqy' => 'application/vnd.mobius.mqy', - 'msl' => 'application/vnd.mobius.msl', - 'plc' => 'application/vnd.mobius.plc', - 'txf' => 'application/vnd.mobius.txf', - 'mpn' => 'application/vnd.mophun.application', - 'mpc' => 'application/vnd.mophun.certificate', - 'xul' => 'application/vnd.mozilla.xul+xml', - 'cil' => 'application/vnd.ms-artgalry', - 'cab' => 'application/vnd.ms-cab-compressed', - 'xls' => 'application/vnd.ms-excel', - 'xlam' => 'application/vnd.ms-excel.addin.macroenabled.12', - 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroenabled.12', - 'xlsm' => 'application/vnd.ms-excel.sheet.macroenabled.12', - 'xltm' => 'application/vnd.ms-excel.template.macroenabled.12', - 'eot' => 'application/vnd.ms-fontobject', - 'chm' => 'application/vnd.ms-htmlhelp', - 'ims' => 'application/vnd.ms-ims', - 'lrm' => 'application/vnd.ms-lrm', - 'thmx' => 'application/vnd.ms-officetheme', - 'cat' => 'application/vnd.ms-pki.seccat', - 'stl' => 'application/vnd.ms-pki.stl', - 'ppt' => 'application/vnd.ms-powerpoint', - 'ppam' => 'application/vnd.ms-powerpoint.addin.macroenabled.12', - 'pptm' => 'application/vnd.ms-powerpoint.presentation.macroenabled.12', - 'sldm' => 'application/vnd.ms-powerpoint.slide.macroenabled.12', - 'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', - 'potm' => 'application/vnd.ms-powerpoint.template.macroenabled.12', - 'mpp' => 'application/vnd.ms-project', - 'docm' => 'application/vnd.ms-word.document.macroenabled.12', - 'dotm' => 'application/vnd.ms-word.template.macroenabled.12', - 'wps' => 'application/vnd.ms-works', - 'wpl' => 'application/vnd.ms-wpl', - 'xps' => 'application/vnd.ms-xpsdocument', - 'mseq' => 'application/vnd.mseq', - 'mus' => 'application/vnd.musician', - 'msty' => 'application/vnd.muvee.style', - 'taglet' => 'application/vnd.mynfc', - 'nlu' => 'application/vnd.neurolanguage.nlu', - 'ntf' => 'application/vnd.nitf', - 'nnd' => 'application/vnd.noblenet-directory', - 'nns' => 'application/vnd.noblenet-sealer', - 'nnw' => 'application/vnd.noblenet-web', - 'ngdat' => 'application/vnd.nokia.n-gage.data', - 'n-gage' => 'application/vnd.nokia.n-gage.symbian.install', - 'rpst' => 'application/vnd.nokia.radio-preset', - 'rpss' => 'application/vnd.nokia.radio-presets', - 'edm' => 'application/vnd.novadigm.edm', - 'edx' => 'application/vnd.novadigm.edx', - 'ext' => 'application/vnd.novadigm.ext', - 'odc' => 'application/vnd.oasis.opendocument.chart', - 'otc' => 'application/vnd.oasis.opendocument.chart-template', - 'odb' => 'application/vnd.oasis.opendocument.database', - 'odf' => 'application/vnd.oasis.opendocument.formula', - 'odft' => 'application/vnd.oasis.opendocument.formula-template', - 'odg' => 'application/vnd.oasis.opendocument.graphics', - 'otg' => 'application/vnd.oasis.opendocument.graphics-template', - 'odi' => 'application/vnd.oasis.opendocument.image', - 'oti' => 'application/vnd.oasis.opendocument.image-template', - 'odp' => 'application/vnd.oasis.opendocument.presentation', - 'otp' => 'application/vnd.oasis.opendocument.presentation-template', - 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', - 'ots' => 'application/vnd.oasis.opendocument.spreadsheet-template', - 'odt' => 'application/vnd.oasis.opendocument.text', - 'odm' => 'application/vnd.oasis.opendocument.text-master', - 'ott' => 'application/vnd.oasis.opendocument.text-template', - 'oth' => 'application/vnd.oasis.opendocument.text-web', - 'xo' => 'application/vnd.olpc-sugar', - 'dd2' => 'application/vnd.oma.dd2+xml', - 'oxt' => 'application/vnd.openofficeorg.extension', - 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide', - 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', - 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', - 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', - 'mgp' => 'application/vnd.osgeo.mapguide.package', - 'dp' => 'application/vnd.osgi.dp', - 'esa' => 'application/vnd.osgi.subsystem', - 'pdb' => 'application/vnd.palm', - 'paw' => 'application/vnd.pawaafile', - 'str' => 'application/vnd.pg.format', - 'ei6' => 'application/vnd.pg.osasli', - 'efif' => 'application/vnd.picsel', - 'wg' => 'application/vnd.pmi.widget', - 'plf' => 'application/vnd.pocketlearn', - 'pbd' => 'application/vnd.powerbuilder6', - 'box' => 'application/vnd.previewsystems.box', - 'mgz' => 'application/vnd.proteus.magazine', - 'qps' => 'application/vnd.publishare-delta-tree', - 'ptid' => 'application/vnd.pvi.ptid1', - 'qxd' => 'application/vnd.quark.quarkxpress', - 'bed' => 'application/vnd.realvnc.bed', - 'mxl' => 'application/vnd.recordare.musicxml', - 'musicxml' => 'application/vnd.recordare.musicxml+xml', - 'cryptonote' => 'application/vnd.rig.cryptonote', - 'cod' => 'application/vnd.rim.cod', - 'rm' => 'application/vnd.rn-realmedia', - 'rmvb' => 'application/vnd.rn-realmedia-vbr', - 'link66' => 'application/vnd.route66.link66+xml', - 'st' => 'application/vnd.sailingtracker.track', - 'see' => 'application/vnd.seemail', - 'sema' => 'application/vnd.sema', - 'semd' => 'application/vnd.semd', - 'semf' => 'application/vnd.semf', - 'ifm' => 'application/vnd.shana.informed.formdata', - 'itp' => 'application/vnd.shana.informed.formtemplate', - 'iif' => 'application/vnd.shana.informed.interchange', - 'ipk' => 'application/vnd.shana.informed.package', - 'twd' => 'application/vnd.simtech-mindmapper', - 'mmf' => 'application/vnd.smaf', - 'teacher' => 'application/vnd.smart.teacher', - 'sdkm' => 'application/vnd.solent.sdkm+xml', - 'dxp' => 'application/vnd.spotfire.dxp', - 'sfs' => 'application/vnd.spotfire.sfs', - 'sdc' => 'application/vnd.stardivision.calc', - 'sda' => 'application/vnd.stardivision.draw', - 'sdd' => 'application/vnd.stardivision.impress', - 'smf' => 'application/vnd.stardivision.math', - 'sdw' => 'application/vnd.stardivision.writer', - 'sgl' => 'application/vnd.stardivision.writer-global', - 'smzip' => 'application/vnd.stepmania.package', - 'sm' => 'application/vnd.stepmania.stepchart', - 'sxc' => 'application/vnd.sun.xml.calc', - 'stc' => 'application/vnd.sun.xml.calc.template', - 'sxd' => 'application/vnd.sun.xml.draw', - 'std' => 'application/vnd.sun.xml.draw.template', - 'sxi' => 'application/vnd.sun.xml.impress', - 'sti' => 'application/vnd.sun.xml.impress.template', - 'sxm' => 'application/vnd.sun.xml.math', - 'sxw' => 'application/vnd.sun.xml.writer', - 'sxg' => 'application/vnd.sun.xml.writer.global', - 'stw' => 'application/vnd.sun.xml.writer.template', - 'sus' => 'application/vnd.sus-calendar', - 'svd' => 'application/vnd.svd', - 'sis' => 'application/vnd.symbian.install', - 'xsm' => 'application/vnd.syncml+xml', - 'bdm' => 'application/vnd.syncml.dm+wbxml', - 'xdm' => 'application/vnd.syncml.dm+xml', - 'tao' => 'application/vnd.tao.intent-module-archive', - 'pcap' => 'application/vnd.tcpdump.pcap', - 'tmo' => 'application/vnd.tmobile-livetv', - 'tpt' => 'application/vnd.trid.tpt', - 'mxs' => 'application/vnd.triscape.mxs', - 'tra' => 'application/vnd.trueapp', - 'ufd' => 'application/vnd.ufdl', - 'utz' => 'application/vnd.uiq.theme', - 'umj' => 'application/vnd.umajin', - 'unityweb' => 'application/vnd.unity', - 'uoml' => 'application/vnd.uoml+xml', - 'vcx' => 'application/vnd.vcx', - 'vsd' => 'application/vnd.visio', - 'vis' => 'application/vnd.visionary', - 'vsf' => 'application/vnd.vsf', - 'wbxml' => 'application/vnd.wap.wbxml', - 'wmlc' => 'application/vnd.wap.wmlc', - 'wmlsc' => 'application/vnd.wap.wmlscriptc', - 'wtb' => 'application/vnd.webturbo', - 'nbp' => 'application/vnd.wolfram.player', - 'wpd' => 'application/vnd.wordperfect', - 'wqd' => 'application/vnd.wqd', - 'stf' => 'application/vnd.wt.stf', - 'xar' => 'application/vnd.xara', - 'xfdl' => 'application/vnd.xfdl', - 'hvd' => 'application/vnd.yamaha.hv-dic', - 'hvs' => 'application/vnd.yamaha.hv-script', - 'hvp' => 'application/vnd.yamaha.hv-voice', - 'osf' => 'application/vnd.yamaha.openscoreformat', - 'osfpvg' => 'application/vnd.yamaha.openscoreformat.osfpvg+xml', - 'saf' => 'application/vnd.yamaha.smaf-audio', - 'spf' => 'application/vnd.yamaha.smaf-phrase', - 'cmp' => 'application/vnd.yellowriver-custom-menu', - 'zir' => 'application/vnd.zul', - 'zaz' => 'application/vnd.zzazz.deck+xml', - 'vxml' => 'application/voicexml+xml', - 'wgt' => 'application/widget', - 'hlp' => 'application/winhlp', - 'wsdl' => 'application/wsdl+xml', - 'wspolicy' => 'application/wspolicy+xml', - '7z' => 'application/x-7z-compressed', - 'abw' => 'application/x-abiword', - 'ace' => 'application/x-ace-compressed', - 'dmg' => 'application/x-apple-diskimage', - 'aab' => 'application/x-authorware-bin', - 'aam' => 'application/x-authorware-map', - 'aas' => 'application/x-authorware-seg', - 'bcpio' => 'application/x-bcpio', - 'torrent' => 'application/x-bittorrent', - 'blb' => 'application/x-blorb', - 'bz' => 'application/x-bzip', - 'bz2' => 'application/x-bzip2', - 'cbr' => 'application/x-cbr', - 'vcd' => 'application/x-cdlink', - 'cfs' => 'application/x-cfs-compressed', - 'chat' => 'application/x-chat', - 'pgn' => 'application/x-chess-pgn', - 'nsc' => 'application/x-conference', - 'cpio' => 'application/x-cpio', - 'csh' => 'application/x-csh', - 'deb' => 'application/x-debian-package', - 'dgc' => 'application/x-dgc-compressed', - 'dir' => 'application/x-director', - 'wad' => 'application/x-doom', - 'ncx' => 'application/x-dtbncx+xml', - 'dtb' => 'application/x-dtbook+xml', - 'res' => 'application/x-dtbresource+xml', - 'dvi' => 'application/x-dvi', - 'evy' => 'application/x-envoy', - 'eva' => 'application/x-eva', - 'bdf' => 'application/x-font-bdf', - 'gsf' => 'application/x-font-ghostscript', - 'psf' => 'application/x-font-linux-psf', - 'otf' => 'application/x-font-otf', - 'pcf' => 'application/x-font-pcf', - 'snf' => 'application/x-font-snf', - 'ttf' => 'application/x-font-ttf', - 'pfa' => 'application/x-font-type1', - 'woff' => 'application/x-font-woff', - 'arc' => 'application/x-freearc', - 'spl' => 'application/x-futuresplash', - 'gca' => 'application/x-gca-compressed', - 'ulx' => 'application/x-glulx', - 'gnumeric' => 'application/x-gnumeric', - 'gramps' => 'application/x-gramps-xml', - 'gtar' => 'application/x-gtar', - 'hdf' => 'application/x-hdf', - 'install' => 'application/x-install-instructions', - 'iso' => 'application/x-iso9660-image', - 'jnlp' => 'application/x-java-jnlp-file', - 'latex' => 'application/x-latex', - 'lzh' => 'application/x-lzh-compressed', - 'mie' => 'application/x-mie', - 'prc' => 'application/x-mobipocket-ebook', - 'application' => 'application/x-ms-application', - 'lnk' => 'application/x-ms-shortcut', - 'wmd' => 'application/x-ms-wmd', - 'wmz' => 'application/x-ms-wmz', - 'xbap' => 'application/x-ms-xbap', - 'mdb' => 'application/x-msaccess', - 'obd' => 'application/x-msbinder', - 'crd' => 'application/x-mscardfile', - 'clp' => 'application/x-msclip', - 'exe' => 'application/x-msdownload', - 'mvb' => 'application/x-msmediaview', - 'wmf' => 'application/x-msmetafile', - 'mny' => 'application/x-msmoney', - 'pub' => 'application/x-mspublisher', - 'scd' => 'application/x-msschedule', - 'trm' => 'application/x-msterminal', - 'wri' => 'application/x-mswrite', - 'nc' => 'application/x-netcdf', - 'nzb' => 'application/x-nzb', - 'p12' => 'application/x-pkcs12', - 'p7b' => 'application/x-pkcs7-certificates', - 'p7r' => 'application/x-pkcs7-certreqresp', - 'rar' => 'application/x-rar', - 'ris' => 'application/x-research-info-systems', - 'sh' => 'application/x-sh', - 'shar' => 'application/x-shar', - 'swf' => 'application/x-shockwave-flash', - 'xap' => 'application/x-silverlight-app', - 'sql' => 'application/x-sql', - 'sit' => 'application/x-stuffit', - 'sitx' => 'application/x-stuffitx', - 'srt' => 'application/x-subrip', - 'sv4cpio' => 'application/x-sv4cpio', - 'sv4crc' => 'application/x-sv4crc', - 't3' => 'application/x-t3vm-image', - 'gam' => 'application/x-tads', - 'tar' => 'application/x-tar', - 'tcl' => 'application/x-tcl', - 'tex' => 'application/x-tex', - 'tfm' => 'application/x-tex-tfm', - 'texinfo' => 'application/x-texinfo', - 'obj' => 'application/x-tgif', - 'ustar' => 'application/x-ustar', - 'src' => 'application/x-wais-source', - 'der' => 'application/x-x509-ca-cert', - 'fig' => 'application/x-xfig', - 'xlf' => 'application/x-xliff+xml', - 'xpi' => 'application/x-xpinstall', - 'xz' => 'application/x-xz', - 'z1' => 'application/x-zmachine', - 'xaml' => 'application/xaml+xml', - 'xdf' => 'application/xcap-diff+xml', - 'xenc' => 'application/xenc+xml', - 'xhtml' => 'application/xhtml+xml', - 'xml' => 'application/xml', - 'dtd' => 'application/xml-dtd', - 'xop' => 'application/xop+xml', - 'xpl' => 'application/xproc+xml', - 'xslt' => 'application/xslt+xml', - 'xspf' => 'application/xspf+xml', - 'mxml' => 'application/xv+xml', - 'yang' => 'application/yang', - 'yin' => 'application/yin+xml', - 'zip' => 'application/zip', - 'adp' => 'audio/adpcm', - 'au' => 'audio/basic', - 'mid' => 'audio/midi', - 'mp3' => 'audio/mpeg', - 'mp4a' => 'audio/mp4', - 'mpga' => 'audio/mpeg', - 'oga' => 'audio/ogg', - 's3m' => 'audio/s3m', - 'sil' => 'audio/silk', - 'uva' => 'audio/vnd.dece.audio', - 'eol' => 'audio/vnd.digital-winds', - 'dra' => 'audio/vnd.dra', - 'dts' => 'audio/vnd.dts', - 'dtshd' => 'audio/vnd.dts.hd', - 'lvp' => 'audio/vnd.lucent.voice', - 'pya' => 'audio/vnd.ms-playready.media.pya', - 'ecelp4800' => 'audio/vnd.nuera.ecelp4800', - 'ecelp7470' => 'audio/vnd.nuera.ecelp7470', - 'ecelp9600' => 'audio/vnd.nuera.ecelp9600', - 'rip' => 'audio/vnd.rip', - 'weba' => 'audio/webm', - 'aac' => 'audio/x-aac', - 'aif' => 'audio/x-aiff', - 'caf' => 'audio/x-caf', - 'flac' => 'audio/x-flac', - 'mka' => 'audio/x-matroska', - 'm3u' => 'audio/x-mpegurl', - 'wax' => 'audio/x-ms-wax', - 'wma' => 'audio/x-ms-wma', - 'ram' => 'audio/x-pn-realaudio', - 'rmp' => 'audio/x-pn-realaudio-plugin', - 'wav' => 'audio/x-wav', - 'xm' => 'audio/xm', - 'cdx' => 'chemical/x-cdx', - 'cif' => 'chemical/x-cif', - 'cmdf' => 'chemical/x-cmdf', - 'cml' => 'chemical/x-cml', - 'csml' => 'chemical/x-csml', - 'xyz' => 'chemical/x-xyz', - 'bmp' => 'image/bmp', - 'cgm' => 'image/cgm', - 'g3' => 'image/g3fax', - 'gif' => 'image/gif', - 'ief' => 'image/ief', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'ktx' => 'image/ktx', - 'png' => 'image/png', - 'btif' => 'image/prs.btif', - 'sgi' => 'image/sgi', - 'svg' => 'image/svg+xml', - 'tiff' => 'image/tiff', - 'psd' => 'image/vnd.adobe.photoshop', - 'uvi' => 'image/vnd.dece.graphic', - 'djvu' => 'image/vnd.djvu', - 'dwg' => 'image/vnd.dwg', - 'dxf' => 'image/vnd.dxf', - 'fbs' => 'image/vnd.fastbidsheet', - 'fpx' => 'image/vnd.fpx', - 'fst' => 'image/vnd.fst', - 'mmr' => 'image/vnd.fujixerox.edmics-mmr', - 'rlc' => 'image/vnd.fujixerox.edmics-rlc', - 'mdi' => 'image/vnd.ms-modi', - 'wdp' => 'image/vnd.ms-photo', - 'npx' => 'image/vnd.net-fpx', - 'wbmp' => 'image/vnd.wap.wbmp', - 'xif' => 'image/vnd.xiff', - 'webp' => 'image/webp', - '3ds' => 'image/x-3ds', - 'ras' => 'image/x-cmu-raster', - 'cmx' => 'image/x-cmx', - 'fh' => 'image/x-freehand', - 'ico' => 'image/x-icon', - 'sid' => 'image/x-mrsid-image', - 'pcx' => 'image/x-pcx', - 'pic' => 'image/x-pict', - 'pnm' => 'image/x-portable-anymap', - 'pbm' => 'image/x-portable-bitmap', - 'pgm' => 'image/x-portable-graymap', - 'ppm' => 'image/x-portable-pixmap', - 'rgb' => 'image/x-rgb', - 'tga' => 'image/x-tga', - 'xbm' => 'image/x-xbitmap', - 'xpm' => 'image/x-xpixmap', - 'xwd' => 'image/x-xwindowdump', - 'eml' => 'message/rfc822', - 'igs' => 'model/iges', - 'msh' => 'model/mesh', - 'dae' => 'model/vnd.collada+xml', - 'dwf' => 'model/vnd.dwf', - 'gdl' => 'model/vnd.gdl', - 'gtw' => 'model/vnd.gtw', - 'mts' => 'model/vnd.mts', - 'vtu' => 'model/vnd.vtu', - 'wrl' => 'model/vrml', - 'x3db' => 'model/x3d+binary', - 'x3dv' => 'model/x3d+vrml', - 'x3d' => 'model/x3d+xml', - 'appcache' => 'text/cache-manifest', - 'ics' => 'text/calendar', - 'css' => 'text/css', - 'csv' => 'text/csv', - 'html' => 'text/html', - 'n3' => 'text/n3', - 'txt' => 'text/plain', - 'dsc' => 'text/prs.lines.tag', - 'rtx' => 'text/richtext', - 'rtf' => 'text/rtf', - 'sgml' => 'text/sgml', - 'tsv' => 'text/tab-separated-values', - 't' => 'text/troff', - 'ttl' => 'text/turtle', - 'uri' => 'text/uri-list', - 'vcard' => 'text/vcard', - 'curl' => 'text/vnd.curl', - 'dcurl' => 'text/vnd.curl.dcurl', - 'scurl' => 'text/vnd.curl.scurl', - 'mcurl' => 'text/vnd.curl.mcurl', - 'sub' => 'text/vnd.dvb.subtitle', - 'fly' => 'text/vnd.fly', - 'flx' => 'text/vnd.fmi.flexstor', - 'gv' => 'text/vnd.graphviz', - '3dml' => 'text/vnd.in3d.3dml', - 'spot' => 'text/vnd.in3d.spot', - 'jad' => 'text/vnd.sun.j2me.app-descriptor', - 'wml' => 'text/vnd.wap.wml', - 'wmls' => 'text/vnd.wap.wmlscript', - 's' => 'text/x-asm', - 'c' => 'text/x-c', - 'f' => 'text/x-fortran', - 'p' => 'text/x-pascal', - 'java' => 'text/x-java-source', - 'opml' => 'text/x-opml', - 'nfo' => 'text/x-nfo', - 'etx' => 'text/x-setext', - 'sfv' => 'text/x-sfv', - 'uu' => 'text/x-uuencode', - 'vcs' => 'text/x-vcalendar', - 'vcf' => 'text/x-vcard', - '3gp' => 'video/3gpp', - '3g2' => 'video/3gpp2', - 'h261' => 'video/h261', - 'h263' => 'video/h263', - 'h264' => 'video/h264', - 'jpgv' => 'video/jpeg', - 'jpm' => 'video/jpm', - 'mj2' => 'video/mj2', - 'mp4' => 'video/mp4', - 'mpeg' => 'video/mpeg', - 'ogv' => 'video/ogg', - 'mov' => 'video/quicktime', - 'qt' => 'video/quicktime', - 'uvh' => 'video/vnd.dece.hd', - 'uvm' => 'video/vnd.dece.mobile', - 'uvp' => 'video/vnd.dece.pd', - 'uvs' => 'video/vnd.dece.sd', - 'uvv' => 'video/vnd.dece.video', - 'dvb' => 'video/vnd.dvb.file', - 'fvt' => 'video/vnd.fvt', - 'mxu' => 'video/vnd.mpegurl', - 'pyv' => 'video/vnd.ms-playready.media.pyv', - 'uvu' => 'video/vnd.uvvu.mp4', - 'viv' => 'video/vnd.vivo', - 'webm' => 'video/webm', - 'f4v' => 'video/x-f4v', - 'fli' => 'video/x-fli', - 'flv' => 'video/x-flv', - 'm4v' => 'video/x-m4v', - 'mkv' => 'video/x-matroska', - 'mng' => 'video/x-mng', - 'asf' => 'video/x-ms-asf', - 'vob' => 'video/x-ms-vob', - 'wm' => 'video/x-ms-wm', - 'wmv' => 'video/x-ms-wmv', - 'wmx' => 'video/x-ms-wmx', - 'wvx' => 'video/x-ms-wvx', - 'avi' => 'video/x-msvideo', - 'movie' => 'video/x-sgi-movie', - 'smv' => 'video/x-smv', - 'ice' => 'x-conference/x-cooltalk', - ]; + private static $mime; + + /** + * Get the mime types instance. + * + * @return \Symfony\Component\Mime\MimeTypesInterface + */ + public static function getMimeTypes() + { + if (self::$mime === null) { + self::$mime = new MimeTypes; + } + + return self::$mime; + } /** * Get the MIME type for a file based on the file's extension. @@ -789,18 +38,18 @@ class MimeType { $extension = pathinfo($filename, PATHINFO_EXTENSION); - return self::getMimeTypeFromExtension($extension); + return self::get($extension); } /** * Get the MIME type for a given extension or return all mimes. * - * @param string|null $extension - * @return string|array + * @param string $extension + * @return string */ - public static function get($extension = null) + public static function get($extension) { - return $extension ? self::getMimeTypeFromExtension($extension) : self::$mimes; + return Arr::first(self::getMimeTypes()->getMimeTypes($extension)) ?? 'application/octet-stream'; } /** @@ -811,17 +60,6 @@ class MimeType */ public static function search($mimeType) { - return array_search($mimeType, self::$mimes) ?: null; - } - - /** - * Get the MIME type for a given extension. - * - * @param string $extension - * @return string - */ - protected static function getMimeTypeFromExtension($extension) - { - return self::$mimes[$extension] ?? 'application/octet-stream'; + return Arr::first(self::getMimeTypes()->getExtensions($mimeType)); } } diff --git a/src/Illuminate/Http/UploadedFile.php b/src/Illuminate/Http/UploadedFile.php index c3a8eab12a5ede012df69ad14c43785b17df2cc9..7779683e4d0a2664621ccf5b8ea3df96b5b45e54 100644 --- a/src/Illuminate/Http/UploadedFile.php +++ b/src/Illuminate/Http/UploadedFile.php @@ -91,14 +91,14 @@ class UploadedFile extends SymfonyUploadedFile /** * Get the contents of the uploaded file. * - * @return bool|string + * @return false|string * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ public function get() { if (! $this->isValid()) { - throw new FileNotFoundException("File does not exist at path {$this->getPathname()}"); + throw new FileNotFoundException("File does not exist at path {$this->getPathname()}."); } return file_get_contents($this->getPathname()); diff --git a/src/Illuminate/Http/composer.json b/src/Illuminate/Http/composer.json index c53a73b94dfb51f26b99eb526001530ace1230a6..564c398d68a7fd29e26344df63260d8aca536245 100755 --- a/src/Illuminate/Http/composer.json +++ b/src/Illuminate/Http/composer.json @@ -14,12 +14,15 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/session": "^6.0", - "illuminate/support": "^6.0", - "symfony/http-foundation": "^4.3.4", - "symfony/http-kernel": "^4.3.4" + "illuminate/collections": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/session": "^8.0", + "illuminate/support": "^8.0", + "symfony/http-foundation": "^5.4", + "symfony/http-kernel": "^5.4", + "symfony/mime": "^5.4" }, "autoload": { "psr-4": { @@ -27,11 +30,12 @@ } }, "suggest": { - "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image()." + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "guzzlehttp/guzzle": "Required to use the HTTP Client (^6.5.5|^7.0.1)." }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Log/LogManager.php b/src/Illuminate/Log/LogManager.php index ab9bf51a15a43c817e4d9c3da9c7c0e908318cd2..44601a7e32596228be1a396e714e5084f61cffd6 100644 --- a/src/Illuminate/Log/LogManager.php +++ b/src/Illuminate/Log/LogManager.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Monolog\Formatter\LineFormatter; use Monolog\Handler\ErrorLogHandler; +use Monolog\Handler\FingersCrossedHandler; use Monolog\Handler\FormattableHandlerInterface; use Monolog\Handler\HandlerInterface; use Monolog\Handler\RotatingFileHandler; @@ -61,6 +62,19 @@ class LogManager implements LoggerInterface $this->app = $app; } + /** + * Build an on-demand log channel. + * + * @param array $config + * @return \Psr\Log\LoggerInterface + */ + public function build(array $config) + { + unset($this->channels['ondemand']); + + return $this->get('ondemand', $config); + } + /** * Create a new, on-demand aggregate logger instance. * @@ -95,27 +109,20 @@ class LogManager implements LoggerInterface */ public function driver($driver = null) { - return $this->get($driver ?? $this->getDefaultDriver()); - } - - /** - * @return array - */ - public function getChannels() - { - return $this->channels; + return $this->get($this->parseDriver($driver)); } /** * Attempt to get the log from the local cache. * * @param string $name + * @param array|null $config * @return \Psr\Log\LoggerInterface */ - protected function get($name) + protected function get($name, ?array $config = null) { try { - return $this->channels[$name] ?? with($this->resolve($name), function ($logger) use ($name) { + return $this->channels[$name] ?? with($this->resolve($name, $config), function ($logger) use ($name) { return $this->channels[$name] = $this->tap($name, new Logger($logger, $this->app['events'])); }); } catch (Throwable $e) { @@ -180,13 +187,14 @@ class LogManager implements LoggerInterface * Resolve the given log instance by name. * * @param string $name + * @param array|null $config * @return \Psr\Log\LoggerInterface * * @throws \InvalidArgumentException */ - protected function resolve($name) + protected function resolve($name, ?array $config = null) { - $config = $this->configurationFor($name); + $config = $config ?? $this->configurationFor($name); if (is_null($config)) { throw new InvalidArgumentException("Log [{$name}] is not defined."); @@ -237,15 +245,27 @@ class LogManager implements LoggerInterface */ protected function createStackDriver(array $config) { + if (is_string($config['channels'])) { + $config['channels'] = explode(',', $config['channels']); + } + $handlers = collect($config['channels'])->flatMap(function ($channel) { - return $this->channel($channel)->getHandlers(); + return $channel instanceof LoggerInterface + ? $channel->getHandlers() + : $this->channel($channel)->getHandlers(); + })->all(); + + $processors = collect($config['channels'])->flatMap(function ($channel) { + return $channel instanceof LoggerInterface + ? $channel->getProcessors() + : $this->channel($channel)->getProcessors(); })->all(); if ($config['ignore_exceptions'] ?? false) { $handlers = [new WhatFailureGroupHandler($handlers)]; } - return new Monolog($this->parseChannel($config), $handlers); + return new Monolog($this->parseChannel($config), $handlers, $processors); } /** @@ -389,17 +409,17 @@ class LogManager implements LoggerInterface */ protected function prepareHandler(HandlerInterface $handler, array $config = []) { - $isHandlerFormattable = false; + if (isset($config['action_level'])) { + $handler = new FingersCrossedHandler($handler, $this->actionLevel($config)); + } - if (Monolog::API === 1) { - $isHandlerFormattable = true; - } elseif (Monolog::API === 2 && $handler instanceof FormattableHandlerInterface) { - $isHandlerFormattable = true; + if (Monolog::API !== 1 && (Monolog::API !== 2 || ! $handler instanceof FormattableHandlerInterface)) { + return $handler; } - if ($isHandlerFormattable && ! isset($config['formatter'])) { + if (! isset($config['formatter'])) { $handler->setFormatter($this->formatter()); - } elseif ($isHandlerFormattable && $config['formatter'] !== 'default') { + } elseif ($config['formatter'] !== 'default') { $handler->setFormatter($this->app->make($config['formatter'], $config['formatter_with'] ?? [])); } @@ -442,7 +462,7 @@ class LogManager implements LoggerInterface /** * Get the default log driver name. * - * @return string + * @return string|null */ public function getDefaultDriver() { @@ -482,19 +502,45 @@ class LogManager implements LoggerInterface */ public function forgetChannel($driver = null) { - $driver = $driver ?? $this->getDefaultDriver(); + $driver = $this->parseDriver($driver); if (isset($this->channels[$driver])) { unset($this->channels[$driver]); } } + /** + * Parse the driver name. + * + * @param string|null $driver + * @return string|null + */ + protected function parseDriver($driver) + { + $driver = $driver ?? $this->getDefaultDriver(); + + if ($this->app->runningUnitTests()) { + $driver = $driver ?? 'null'; + } + + return $driver; + } + + /** + * Get all of the resolved log channels. + * + * @return array + */ + public function getChannels() + { + return $this->channels; + } + /** * System is unusable. * * @param string $message * @param array $context - * * @return void */ public function emergency($message, array $context = []) @@ -510,7 +556,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function alert($message, array $context = []) @@ -525,7 +570,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function critical($message, array $context = []) @@ -539,7 +583,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function error($message, array $context = []) @@ -555,7 +598,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function warning($message, array $context = []) @@ -568,7 +610,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function notice($message, array $context = []) @@ -583,7 +624,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function info($message, array $context = []) @@ -596,7 +636,6 @@ class LogManager implements LoggerInterface * * @param string $message * @param array $context - * * @return void */ public function debug($message, array $context = []) @@ -610,7 +649,6 @@ class LogManager implements LoggerInterface * @param mixed $level * @param string $message * @param array $context - * * @return void */ public function log($level, $message, array $context = []) diff --git a/src/Illuminate/Log/LogServiceProvider.php b/src/Illuminate/Log/LogServiceProvider.php index cd0739211932cd615e4481f262faafc2d3f59323..ebe545305bcbeb1a226c8c0edd1dc6381c540539 100644 --- a/src/Illuminate/Log/LogServiceProvider.php +++ b/src/Illuminate/Log/LogServiceProvider.php @@ -13,8 +13,8 @@ class LogServiceProvider extends ServiceProvider */ public function register() { - $this->app->singleton('log', function () { - return new LogManager($this->app); + $this->app->singleton('log', function ($app) { + return new LogManager($app); }); } } diff --git a/src/Illuminate/Log/Logger.php b/src/Illuminate/Log/Logger.php index 57f2c983241754263de95753ad91a6110d33aad7..382b77c6449feae5199c9b4ae4a76208898c34dc 100755 --- a/src/Illuminate/Log/Logger.php +++ b/src/Illuminate/Log/Logger.php @@ -26,6 +26,13 @@ class Logger implements LoggerInterface */ protected $dispatcher; + /** + * Any context to be added to logs. + * + * @var array + */ + protected $context = []; + /** * Create a new log writer instance. * @@ -171,9 +178,37 @@ class Logger implements LoggerInterface */ protected function writeLog($level, $message, $context) { - $this->fireLogEvent($level, $message = $this->formatMessage($message), $context); + $this->logger->{$level}( + $message = $this->formatMessage($message), + $context = array_merge($this->context, $context) + ); + + $this->fireLogEvent($level, $message, $context); + } + + /** + * Add context to all future logs. + * + * @param array $context + * @return $this + */ + public function withContext(array $context = []) + { + $this->context = array_merge($this->context, $context); + + return $this; + } + + /** + * Flush the existing context array. + * + * @return $this + */ + public function withoutContext() + { + $this->context = []; - $this->logger->{$level}($message, $context); + return $this; } /** diff --git a/src/Illuminate/Log/ParsesLogConfiguration.php b/src/Illuminate/Log/ParsesLogConfiguration.php index f40cf6b504957b97f28550607d6b1ee1ffa82311..fd0d5ed5702167ffaf14263e5a79d23221b30701 100644 --- a/src/Illuminate/Log/ParsesLogConfiguration.php +++ b/src/Illuminate/Log/ParsesLogConfiguration.php @@ -49,6 +49,23 @@ trait ParsesLogConfiguration throw new InvalidArgumentException('Invalid log level.'); } + /** + * Parse the action level from the given configuration. + * + * @param array $config + * @return int + */ + protected function actionLevel(array $config) + { + $level = $config['action_level'] ?? 'debug'; + + if (isset($this->levels[$level])) { + return $this->levels[$level]; + } + + throw new InvalidArgumentException('Invalid log action level.'); + } + /** * Extract the log channel from the given configuration. * diff --git a/src/Illuminate/Log/composer.json b/src/Illuminate/Log/composer.json index 3bd21d0a91b1d22c299cc2f248082db151712817..1fd148d9a9fd68f79ddf821dc83a346584d92fa9 100755 --- a/src/Illuminate/Log/composer.json +++ b/src/Illuminate/Log/composer.json @@ -14,10 +14,10 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "monolog/monolog": "^1.12|^2.0" + "php": "^7.3|^8.0", + "illuminate/contracts": "^8.0", + "illuminate/support": "^8.0", + "monolog/monolog": "^2.0" }, "autoload": { "psr-4": { @@ -26,7 +26,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Macroable/LICENSE.md b/src/Illuminate/Macroable/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..79810c848f8bdbd8f1629f46079ad482f33fc371 --- /dev/null +++ b/src/Illuminate/Macroable/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Illuminate/Support/Traits/Macroable.php b/src/Illuminate/Macroable/Traits/Macroable.php similarity index 94% rename from src/Illuminate/Support/Traits/Macroable.php rename to src/Illuminate/Macroable/Traits/Macroable.php index 406f65edc79bdef5be3a78adab0d136f5d0a1c90..2269142ec97bf67bc2bb145eb96d8b8c96cb87cc 100644 --- a/src/Illuminate/Support/Traits/Macroable.php +++ b/src/Illuminate/Macroable/Traits/Macroable.php @@ -62,6 +62,16 @@ trait Macroable return isset(static::$macros[$name]); } + /** + * Flush the existing macros. + * + * @return void + */ + public static function flushMacros() + { + static::$macros = []; + } + /** * Dynamically handle calls to the class. * diff --git a/src/Illuminate/Macroable/composer.json b/src/Illuminate/Macroable/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..dfa5c62be192569c750c6d210948299a0d410bbe --- /dev/null +++ b/src/Illuminate/Macroable/composer.json @@ -0,0 +1,33 @@ +{ + "name": "illuminate/macroable", + "description": "The Illuminate Macroable package.", + "license": "MIT", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "require": { + "php": "^7.3|^8.0" + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev" +} diff --git a/src/Illuminate/Mail/MailManager.php b/src/Illuminate/Mail/MailManager.php new file mode 100644 index 0000000000000000000000000000000000000000..05d6d8e3c8429557289fd572ab19f5ba68adf9cb --- /dev/null +++ b/src/Illuminate/Mail/MailManager.php @@ -0,0 +1,552 @@ +<?php + +namespace Illuminate\Mail; + +use Aws\Ses\SesClient; +use Closure; +use GuzzleHttp\Client as HttpClient; +use Illuminate\Contracts\Mail\Factory as FactoryContract; +use Illuminate\Log\LogManager; +use Illuminate\Mail\Transport\ArrayTransport; +use Illuminate\Mail\Transport\LogTransport; +use Illuminate\Mail\Transport\MailgunTransport; +use Illuminate\Mail\Transport\SesTransport; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use InvalidArgumentException; +use Postmark\ThrowExceptionOnFailurePlugin; +use Postmark\Transport as PostmarkTransport; +use Psr\Log\LoggerInterface; +use Swift_DependencyContainer; +use Swift_FailoverTransport as FailoverTransport; +use Swift_Mailer; +use Swift_SendmailTransport as SendmailTransport; +use Swift_SmtpTransport as SmtpTransport; + +/** + * @mixin \Illuminate\Mail\Mailer + */ +class MailManager implements FactoryContract +{ + /** + * The application instance. + * + * @var \Illuminate\Contracts\Foundation\Application + */ + protected $app; + + /** + * The array of resolved mailers. + * + * @var array + */ + protected $mailers = []; + + /** + * The registered custom driver creators. + * + * @var array + */ + protected $customCreators = []; + + /** + * Create a new Mail manager instance. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return void + */ + public function __construct($app) + { + $this->app = $app; + } + + /** + * Get a mailer instance by name. + * + * @param string|null $name + * @return \Illuminate\Contracts\Mail\Mailer + */ + public function mailer($name = null) + { + $name = $name ?: $this->getDefaultDriver(); + + return $this->mailers[$name] = $this->get($name); + } + + /** + * Get a mailer driver instance. + * + * @param string|null $driver + * @return \Illuminate\Mail\Mailer + */ + public function driver($driver = null) + { + return $this->mailer($driver); + } + + /** + * Attempt to get the mailer from the local cache. + * + * @param string $name + * @return \Illuminate\Mail\Mailer + */ + protected function get($name) + { + return $this->mailers[$name] ?? $this->resolve($name); + } + + /** + * Resolve the given mailer. + * + * @param string $name + * @return \Illuminate\Mail\Mailer + * + * @throws \InvalidArgumentException + */ + protected function resolve($name) + { + $config = $this->getConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Mailer [{$name}] is not defined."); + } + + // Once we have created the mailer instance we will set a container instance + // on the mailer. This allows us to resolve mailer classes via containers + // for maximum testability on said classes instead of passing Closures. + $mailer = new Mailer( + $name, + $this->app['view'], + $this->createSwiftMailer($config), + $this->app['events'] + ); + + if ($this->app->bound('queue')) { + $mailer->setQueue($this->app['queue']); + } + + // Next we will set all of the global addresses on this mailer, which allows + // for easy unification of all "from" addresses as well as easy debugging + // of sent messages since these will be sent to a single email address. + foreach (['from', 'reply_to', 'to', 'return_path'] as $type) { + $this->setGlobalAddress($mailer, $config, $type); + } + + return $mailer; + } + + /** + * Create the SwiftMailer instance for the given configuration. + * + * @param array $config + * @return \Swift_Mailer + */ + protected function createSwiftMailer(array $config) + { + if ($config['domain'] ?? false) { + Swift_DependencyContainer::getInstance() + ->register('mime.idgenerator.idright') + ->asValue($config['domain']); + } + + return new Swift_Mailer($this->createTransport($config)); + } + + /** + * Create a new transport instance. + * + * @param array $config + * @return \Swift_Transport + * + * @throws \InvalidArgumentException + */ + public function createTransport(array $config) + { + // Here we will check if the "transport" key exists and if it doesn't we will + // assume an application is still using the legacy mail configuration file + // format and use the "mail.driver" configuration option instead for BC. + $transport = $config['transport'] ?? $this->app['config']['mail.driver']; + + if (isset($this->customCreators[$transport])) { + return call_user_func($this->customCreators[$transport], $config); + } + + if (trim($transport ?? '') === '' || ! method_exists($this, $method = 'create'.ucfirst($transport).'Transport')) { + throw new InvalidArgumentException("Unsupported mail transport [{$transport}]."); + } + + return $this->{$method}($config); + } + + /** + * Create an instance of the SMTP Swift Transport driver. + * + * @param array $config + * @return \Swift_SmtpTransport + */ + protected function createSmtpTransport(array $config) + { + // The Swift SMTP transport instance will allow us to use any SMTP backend + // for delivering mail such as Sendgrid, Amazon SES, or a custom server + // a developer has available. We will just pass this configured host. + $transport = new SmtpTransport( + $config['host'], + $config['port'] + ); + + if (! empty($config['encryption'])) { + $transport->setEncryption($config['encryption']); + } + + // Once we have the transport we will check for the presence of a username + // and password. If we have it we will set the credentials on the Swift + // transporter instance so that we'll properly authenticate delivery. + if (isset($config['username'])) { + $transport->setUsername($config['username']); + + $transport->setPassword($config['password']); + } + + return $this->configureSmtpTransport($transport, $config); + } + + /** + * Configure the additional SMTP driver options. + * + * @param \Swift_SmtpTransport $transport + * @param array $config + * @return \Swift_SmtpTransport + */ + protected function configureSmtpTransport($transport, array $config) + { + if (isset($config['stream'])) { + $transport->setStreamOptions($config['stream']); + } + + if (isset($config['source_ip'])) { + $transport->setSourceIp($config['source_ip']); + } + + if (isset($config['local_domain'])) { + $transport->setLocalDomain($config['local_domain']); + } + + if (isset($config['timeout'])) { + $transport->setTimeout($config['timeout']); + } + + if (isset($config['auth_mode'])) { + $transport->setAuthMode($config['auth_mode']); + } + + return $transport; + } + + /** + * Create an instance of the Sendmail Swift Transport driver. + * + * @param array $config + * @return \Swift_SendmailTransport + */ + protected function createSendmailTransport(array $config) + { + return new SendmailTransport( + $config['path'] ?? $this->app['config']->get('mail.sendmail') + ); + } + + /** + * Create an instance of the Amazon SES Swift Transport driver. + * + * @param array $config + * @return \Illuminate\Mail\Transport\SesTransport + */ + protected function createSesTransport(array $config) + { + $config = array_merge( + $this->app['config']->get('services.ses', []), + ['version' => 'latest', 'service' => 'email'], + $config + ); + + $config = Arr::except($config, ['transport']); + + return new SesTransport( + new SesClient($this->addSesCredentials($config)), + $config['options'] ?? [] + ); + } + + /** + * Add the SES credentials to the configuration array. + * + * @param array $config + * @return array + */ + protected function addSesCredentials(array $config) + { + if (! empty($config['key']) && ! empty($config['secret'])) { + $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); + } + + return $config; + } + + /** + * Create an instance of the Mail Swift Transport driver. + * + * @return \Swift_SendmailTransport + */ + protected function createMailTransport() + { + return new SendmailTransport; + } + + /** + * Create an instance of the Mailgun Swift Transport driver. + * + * @param array $config + * @return \Illuminate\Mail\Transport\MailgunTransport + */ + protected function createMailgunTransport(array $config) + { + if (! isset($config['secret'])) { + $config = $this->app['config']->get('services.mailgun', []); + } + + return new MailgunTransport( + $this->guzzle($config), + $config['secret'], + $config['domain'], + $config['endpoint'] ?? null + ); + } + + /** + * Create an instance of the Postmark Swift Transport driver. + * + * @param array $config + * @return \Swift_Transport + */ + protected function createPostmarkTransport(array $config) + { + $headers = isset($config['message_stream_id']) ? [ + 'X-PM-Message-Stream' => $config['message_stream_id'], + ] : []; + + return tap(new PostmarkTransport( + $config['token'] ?? $this->app['config']->get('services.postmark.token'), + $headers + ), function ($transport) { + $transport->registerPlugin(new ThrowExceptionOnFailurePlugin); + }); + } + + /** + * Create an instance of the Failover Swift Transport driver. + * + * @param array $config + * @return \Swift_FailoverTransport + */ + protected function createFailoverTransport(array $config) + { + $transports = []; + + foreach ($config['mailers'] as $name) { + $config = $this->getConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Mailer [{$name}] is not defined."); + } + + // Now, we will check if the "driver" key exists and if it does we will set + // the transport configuration parameter in order to offer compatibility + // with any Laravel <= 6.x application style mail configuration files. + $transports[] = $this->app['config']['mail.driver'] + ? $this->createTransport(array_merge($config, ['transport' => $name])) + : $this->createTransport($config); + } + + return new FailoverTransport($transports); + } + + /** + * Create an instance of the Log Swift Transport driver. + * + * @param array $config + * @return \Illuminate\Mail\Transport\LogTransport + */ + protected function createLogTransport(array $config) + { + $logger = $this->app->make(LoggerInterface::class); + + if ($logger instanceof LogManager) { + $logger = $logger->channel( + $config['channel'] ?? $this->app['config']->get('mail.log_channel') + ); + } + + return new LogTransport($logger); + } + + /** + * Create an instance of the Array Swift Transport Driver. + * + * @return \Illuminate\Mail\Transport\ArrayTransport + */ + protected function createArrayTransport() + { + return new ArrayTransport; + } + + /** + * Get a fresh Guzzle HTTP client instance. + * + * @param array $config + * @return \GuzzleHttp\Client + */ + protected function guzzle(array $config) + { + return new HttpClient(Arr::add( + $config['guzzle'] ?? [], + 'connect_timeout', + 60 + )); + } + + /** + * Set a global address on the mailer by type. + * + * @param \Illuminate\Mail\Mailer $mailer + * @param array $config + * @param string $type + * @return void + */ + protected function setGlobalAddress($mailer, array $config, string $type) + { + $address = Arr::get($config, $type, $this->app['config']['mail.'.$type]); + + if (is_array($address) && isset($address['address'])) { + $mailer->{'always'.Str::studly($type)}($address['address'], $address['name']); + } + } + + /** + * Get the mail connection configuration. + * + * @param string $name + * @return array + */ + protected function getConfig(string $name) + { + // Here we will check if the "driver" key exists and if it does we will use + // the entire mail configuration file as the "driver" config in order to + // provide "BC" for any Laravel <= 6.x style mail configuration files. + return $this->app['config']['mail.driver'] + ? $this->app['config']['mail'] + : $this->app['config']["mail.mailers.{$name}"]; + } + + /** + * Get the default mail driver name. + * + * @return string + */ + public function getDefaultDriver() + { + // Here we will check if the "driver" key exists and if it does we will use + // that as the default driver in order to provide support for old styles + // of the Laravel mail configuration file for backwards compatibility. + return $this->app['config']['mail.driver'] ?? + $this->app['config']['mail.default']; + } + + /** + * Set the default mail driver name. + * + * @param string $name + * @return void + */ + public function setDefaultDriver(string $name) + { + if ($this->app['config']['mail.driver']) { + $this->app['config']['mail.driver'] = $name; + } + + $this->app['config']['mail.default'] = $name; + } + + /** + * Disconnect the given mailer and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?: $this->getDefaultDriver(); + + unset($this->mailers[$name]); + } + + /** + * Register a custom transport creator Closure. + * + * @param string $driver + * @param \Closure $callback + * @return $this + */ + public function extend($driver, Closure $callback) + { + $this->customCreators[$driver] = $callback; + + return $this; + } + + /** + * Get the application instance used by the manager. + * + * @return \Illuminate\Contracts\Foundation\Application + */ + public function getApplication() + { + return $this->app; + } + + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + return $this; + } + + /** + * Forget all of the resolved mailer instances. + * + * @return $this + */ + public function forgetMailers() + { + $this->mailers = []; + + return $this; + } + + /** + * Dynamically call the default driver instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->mailer()->$method(...$parameters); + } +} diff --git a/src/Illuminate/Mail/MailServiceProvider.php b/src/Illuminate/Mail/MailServiceProvider.php index 0d123b89daf463af5e1d2b25f0581194e85c59e0..d4f4682d94dcf9bf135efa1c63a010d0087c81a9 100755 --- a/src/Illuminate/Mail/MailServiceProvider.php +++ b/src/Illuminate/Mail/MailServiceProvider.php @@ -3,11 +3,7 @@ namespace Illuminate\Mail; use Illuminate\Contracts\Support\DeferrableProvider; -use Illuminate\Support\Arr; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; -use Swift_DependencyContainer; -use Swift_Mailer; class MailServiceProvider extends ServiceProvider implements DeferrableProvider { @@ -18,7 +14,6 @@ class MailServiceProvider extends ServiceProvider implements DeferrableProvider */ public function register() { - $this->registerSwiftMailer(); $this->registerIlluminateMailer(); $this->registerMarkdownRenderer(); } @@ -30,80 +25,12 @@ class MailServiceProvider extends ServiceProvider implements DeferrableProvider */ protected function registerIlluminateMailer() { - $this->app->singleton('mailer', function ($app) { - $config = $app->make('config')->get('mail'); - - // Once we have create the mailer instance, we will set a container instance - // on the mailer. This allows us to resolve mailer classes via containers - // for maximum testability on said classes instead of passing Closures. - $mailer = new Mailer( - $app['view'], $app['swift.mailer'], $app['events'] - ); - - if ($app->bound('queue')) { - $mailer->setQueue($app['queue']); - } - - // Next we will set all of the global addresses on this mailer, which allows - // for easy unification of all "from" addresses as well as easy debugging - // of sent messages since they get be sent into a single email address. - foreach (['from', 'reply_to', 'to'] as $type) { - $this->setGlobalAddress($mailer, $config, $type); - } - - return $mailer; + $this->app->singleton('mail.manager', function ($app) { + return new MailManager($app); }); - } - /** - * Set a global address on the mailer by type. - * - * @param \Illuminate\Mail\Mailer $mailer - * @param array $config - * @param string $type - * @return void - */ - protected function setGlobalAddress($mailer, array $config, $type) - { - $address = Arr::get($config, $type); - - if (is_array($address) && isset($address['address'])) { - $mailer->{'always'.Str::studly($type)}($address['address'], $address['name']); - } - } - - /** - * Register the Swift Mailer instance. - * - * @return void - */ - public function registerSwiftMailer() - { - $this->registerSwiftTransport(); - - // Once we have the transporter registered, we will register the actual Swift - // mailer instance, passing in the transport instances, which allows us to - // override this transporter instances during app start-up if necessary. - $this->app->singleton('swift.mailer', function ($app) { - if ($domain = $app->make('config')->get('mail.domain')) { - Swift_DependencyContainer::getInstance() - ->register('mime.idgenerator.idright') - ->asValue($domain); - } - - return new Swift_Mailer($app['swift.transport']->driver()); - }); - } - - /** - * Register the Swift Transport instance. - * - * @return void - */ - protected function registerSwiftTransport() - { - $this->app->singleton('swift.transport', function ($app) { - return new TransportManager($app); + $this->app->bind('mailer', function ($app) { + return $app->make('mail.manager')->mailer(); }); } @@ -138,7 +65,9 @@ class MailServiceProvider extends ServiceProvider implements DeferrableProvider public function provides() { return [ - 'mailer', 'swift.mailer', 'swift.transport', Markdown::class, + 'mail.manager', + 'mailer', + Markdown::class, ]; } } diff --git a/src/Illuminate/Mail/Mailable.php b/src/Illuminate/Mail/Mailable.php index 177ef7059a83a9faff0fd6b75cb4850b5e395327..3df0074ba03afb1e3da63d72bddc8ef87ad0fe03 100644 --- a/src/Illuminate/Mail/Mailable.php +++ b/src/Illuminate/Mail/Mailable.php @@ -4,21 +4,24 @@ namespace Illuminate\Mail; use Illuminate\Container\Container; use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory; +use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailable as MailableContract; -use Illuminate\Contracts\Mail\Mailer as MailerContract; use Illuminate\Contracts\Queue\Factory as Queue; +use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Renderable; use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; +use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\Localizable; +use PHPUnit\Framework\Assert as PHPUnit; use ReflectionClass; use ReflectionProperty; class Mailable implements MailableContract, Renderable { - use ForwardsCalls, Localizable; + use Conditionable, ForwardsCalls, Localizable; /** * The locale of the message. @@ -74,7 +77,7 @@ class Mailable implements MailableContract, Renderable * * @var string */ - protected $markdown; + public $markdown; /** * The HTML to use for the message. @@ -132,6 +135,27 @@ class Mailable implements MailableContract, Renderable */ public $callbacks = []; + /** + * The name of the theme that should be used when formatting the message. + * + * @var string|null + */ + public $theme; + + /** + * The name of the mailer that should send the message. + * + * @var string + */ + public $mailer; + + /** + * The rendered mailable views for testing / assertions. + * + * @var array + */ + protected $assertionableRenderStrings; + /** * The callback that should be invoked while building the view data. * @@ -142,14 +166,18 @@ class Mailable implements MailableContract, Renderable /** * Send the message using the given mailer. * - * @param \Illuminate\Contracts\Mail\Mailer $mailer + * @param \Illuminate\Contracts\Mail\Factory|\Illuminate\Contracts\Mail\Mailer $mailer * @return void */ - public function send(MailerContract $mailer) + public function send($mailer) { - return $this->withLocale($this->locale, function () use ($mailer) { + $this->withLocale($this->locale, function () use ($mailer) { Container::getInstance()->call([$this, 'build']); + $mailer = $mailer instanceof MailFactory + ? $mailer->mailer($this->mailer) + : $mailer; + return $mailer->send($this->buildView(), $this->buildViewData(), function ($message) { $this->buildFrom($message) ->buildRecipients($message) @@ -206,7 +234,11 @@ class Mailable implements MailableContract, Renderable */ protected function newQueuedJob() { - return new SendQueuedMailable($this); + return (new SendQueuedMailable($this)) + ->through(array_merge( + method_exists($this, 'middleware') ? $this->middleware() : [], + $this->middleware ?? [] + )); } /** @@ -586,6 +618,10 @@ class Mailable implements MailableContract, Renderable */ protected function setAddress($address, $name = null, $property = 'to') { + if (empty($address)) { + return $this; + } + foreach ($this->addressesToArray($address, $name) as $recipient) { $recipient = $this->normalizeRecipient($recipient); @@ -623,6 +659,12 @@ class Mailable implements MailableContract, Renderable protected function normalizeRecipient($recipient) { if (is_array($recipient)) { + if (array_values($recipient) === $recipient) { + return (object) array_map(function ($email) { + return compact('email'); + }, $recipient); + } + return (object) $recipient; } elseif (is_string($recipient)) { return (object) ['email' => $recipient]; @@ -641,6 +683,10 @@ class Mailable implements MailableContract, Renderable */ protected function hasRecipient($address, $name = null, $property = 'to') { + if (empty($address)) { + return false; + } + $expected = $this->normalizeRecipient( $this->addressesToArray($address, $name)[0] ); @@ -820,6 +866,127 @@ class Mailable implements MailableContract, Renderable return $this; } + /** + * Assert that the given text is present in the HTML email body. + * + * @param string $string + * @return $this + */ + public function assertSeeInHtml($string) + { + [$html, $text] = $this->renderForAssertions(); + + PHPUnit::assertTrue( + Str::contains($html, $string), + "Did not see expected text [{$string}] within email body." + ); + + return $this; + } + + /** + * Assert that the given text is not present in the HTML email body. + * + * @param string $string + * @return $this + */ + public function assertDontSeeInHtml($string) + { + [$html, $text] = $this->renderForAssertions(); + + PHPUnit::assertFalse( + Str::contains($html, $string), + "Saw unexpected text [{$string}] within email body." + ); + + return $this; + } + + /** + * Assert that the given text is present in the plain-text email body. + * + * @param string $string + * @return $this + */ + public function assertSeeInText($string) + { + [$html, $text] = $this->renderForAssertions(); + + PHPUnit::assertTrue( + Str::contains($text, $string), + "Did not see expected text [{$string}] within text email body." + ); + + return $this; + } + + /** + * Assert that the given text is not present in the plain-text email body. + * + * @param string $string + * @return $this + */ + public function assertDontSeeInText($string) + { + [$html, $text] = $this->renderForAssertions(); + + PHPUnit::assertFalse( + Str::contains($text, $string), + "Saw unexpected text [{$string}] within text email body." + ); + + return $this; + } + + /** + * Render the HTML and plain-text version of the mailable into views for assertions. + * + * @return array + * + * @throws \ReflectionException + */ + protected function renderForAssertions() + { + if ($this->assertionableRenderStrings) { + return $this->assertionableRenderStrings; + } + + return $this->assertionableRenderStrings = $this->withLocale($this->locale, function () { + Container::getInstance()->call([$this, 'build']); + + $html = Container::getInstance()->make('mailer')->render( + $view = $this->buildView(), $this->buildViewData() + ); + + if (is_array($view) && isset($view[1])) { + $text = $view[1]; + } + + $text = $text ?? $view['text'] ?? ''; + + if (! empty($text) && ! $text instanceof Htmlable) { + $text = Container::getInstance()->make('mailer')->render( + $text, $this->buildViewData() + ); + } + + return [(string) $html, (string) $text]; + }); + } + + /** + * Set the name of the mailer that should send the message. + * + * @param string $mailer + * @return $this + */ + public function mailer($mailer) + { + $this->mailer = $mailer; + + return $this; + } + /** * Register a callback to be called with the Swift message instance. * diff --git a/src/Illuminate/Mail/Mailer.php b/src/Illuminate/Mail/Mailer.php index 7a11e58f68fcd90f5d96e1414902c60541c0da0f..128f211f765113f3002377e13efaa7a314c71549 100755 --- a/src/Illuminate/Mail/Mailer.php +++ b/src/Illuminate/Mail/Mailer.php @@ -21,6 +21,13 @@ class Mailer implements MailerContract, MailQueueContract { use Macroable; + /** + * The name that is configured for the mailer. + * + * @var string + */ + protected $name; + /** * The view factory instance. * @@ -56,6 +63,13 @@ class Mailer implements MailerContract, MailQueueContract */ protected $replyTo; + /** + * The global return path address. + * + * @var array + */ + protected $returnPath; + /** * The global to address and name. * @@ -80,13 +94,15 @@ class Mailer implements MailerContract, MailQueueContract /** * Create a new Mailer instance. * + * @param string $name * @param \Illuminate\Contracts\View\Factory $views * @param \Swift_Mailer $swift * @param \Illuminate\Contracts\Events\Dispatcher|null $events * @return void */ - public function __construct(Factory $views, Swift_Mailer $swift, Dispatcher $events = null) + public function __construct(string $name, Factory $views, Swift_Mailer $swift, Dispatcher $events = null) { + $this->name = $name; $this->views = $views; $this->swift = $swift; $this->events = $events; @@ -116,6 +132,17 @@ class Mailer implements MailerContract, MailQueueContract $this->replyTo = compact('address', 'name'); } + /** + * Set the global return path address. + * + * @param string $address + * @return void + */ + public function alwaysReturnPath($address) + { + $this->returnPath = compact('address'); + } + /** * Set the global to address and name. * @@ -170,7 +197,7 @@ class Mailer implements MailerContract, MailQueueContract */ public function html($html, $callback) { - return $this->send(['html' => new HtmlString($html)], [], $callback); + $this->send(['html' => new HtmlString($html)], [], $callback); } /** @@ -182,7 +209,7 @@ class Mailer implements MailerContract, MailQueueContract */ public function raw($text, $callback) { - return $this->send(['raw' => $text], [], $callback); + $this->send(['raw' => $text], [], $callback); } /** @@ -195,7 +222,7 @@ class Mailer implements MailerContract, MailQueueContract */ public function plain($view, array $data, $callback) { - return $this->send(['text' => $view], $data, $callback); + $this->send(['text' => $view], $data, $callback); } /** @@ -273,8 +300,8 @@ class Mailer implements MailerContract, MailQueueContract protected function sendMailable(MailableContract $mailable) { return $mailable instanceof ShouldQueue - ? $mailable->queue($this->queue) - : $mailable->send($this); + ? $mailable->mailer($this->name)->queue($this->queue) + : $mailable->mailer($this->name)->send($this); } /** @@ -387,7 +414,7 @@ class Mailer implements MailerContract, MailQueueContract $view->onQueue($queue); } - return $view->queue($this->queue); + return $view->mailer($this->name)->queue($this->queue); } /** @@ -432,7 +459,9 @@ class Mailer implements MailerContract, MailQueueContract throw new InvalidArgumentException('Only mailables may be queued.'); } - return $view->later($delay, is_null($queue) ? $this->queue : $queue); + return $view->mailer($this->name)->later( + $delay, is_null($queue) ? $this->queue : $queue + ); } /** @@ -471,6 +500,10 @@ class Mailer implements MailerContract, MailQueueContract $message->replyTo($this->replyTo['address'], $this->replyTo['name']); } + if (! empty($this->returnPath['address'])) { + $message->returnPath($this->returnPath['address']); + } + return $message; } diff --git a/src/Illuminate/Mail/Markdown.php b/src/Illuminate/Mail/Markdown.php index 65b6bdeb9573aa512df08c38adb58b446d431d20..9bd083605c60250574cd4b2f075199f92c3d360b 100644 --- a/src/Illuminate/Mail/Markdown.php +++ b/src/Illuminate/Mail/Markdown.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use League\CommonMark\CommonMarkConverter; -use League\CommonMark\Environment; use League\CommonMark\Extension\Table\TableExtension; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; @@ -63,9 +62,13 @@ class Markdown 'mail', $this->htmlComponentPaths() )->make($view, $data)->render(); - $theme = Str::contains($this->theme, '::') - ? $this->theme - : 'mail::themes.'.$this->theme; + if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) { + $theme = $customTheme; + } else { + $theme = Str::contains($this->theme, '::') + ? $this->theme + : 'mail::themes.'.$this->theme; + } return new HtmlString(($inliner ?: new CssToInlineStyles)->convert( $contents, $this->view->make($theme, $data)->render() @@ -100,15 +103,13 @@ class Markdown */ public static function parse($text) { - $environment = Environment::createCommonMarkEnvironment(); - - $environment->addExtension(new TableExtension); - $converter = new CommonMarkConverter([ 'allow_unsafe_links' => false, - ], $environment); + ]); + + $converter->getEnvironment()->addExtension(new TableExtension()); - return new HtmlString($converter->convertToHtml($text)); + return new HtmlString((string) $converter->convertToHtml($text)); } /** @@ -170,4 +171,14 @@ class Markdown return $this; } + + /** + * Get the theme currently being used by the renderer. + * + * @return string + */ + public function getTheme() + { + return $this->theme; + } } diff --git a/src/Illuminate/Mail/Message.php b/src/Illuminate/Mail/Message.php index d701fba9fb393a09e6ee757d28cde49cce5a1637..cab6c026d9fe9339852d3f6c4f3febdff4a9fb68 100755 --- a/src/Illuminate/Mail/Message.php +++ b/src/Illuminate/Mail/Message.php @@ -137,7 +137,7 @@ class Message } /** - * Add a reply to address to the message. + * Add a "reply to" address to the message. * * @param string|array $address * @param string|null $name diff --git a/src/Illuminate/Mail/PendingMail.php b/src/Illuminate/Mail/PendingMail.php index 7150f8566be8dfb0052b9017972fd2b19a1a1237..8fbabc4bd5aac2422631395cdb4a104984d563b8 100644 --- a/src/Illuminate/Mail/PendingMail.php +++ b/src/Illuminate/Mail/PendingMail.php @@ -5,9 +5,12 @@ namespace Illuminate\Mail; use Illuminate\Contracts\Mail\Mailable as MailableContract; use Illuminate\Contracts\Mail\Mailer as MailerContract; use Illuminate\Contracts\Translation\HasLocalePreference; +use Illuminate\Support\Traits\Conditionable; class PendingMail { + use Conditionable; + /** * The mailer instance. * @@ -114,23 +117,11 @@ class PendingMail * Send a new mailable message instance. * * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed + * @return void */ public function send(MailableContract $mailable) { - return $this->mailer->send($this->fill($mailable)); - } - - /** - * Send a mailable message immediately. - * - * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed - * @deprecated Use send() instead. - */ - public function sendNow(MailableContract $mailable) - { - return $this->mailer->send($this->fill($mailable)); + $this->mailer->send($this->fill($mailable)); } /** @@ -166,7 +157,7 @@ class PendingMail { return tap($mailable->to($this->to) ->cc($this->cc) - ->bcc($this->bcc), function ($mailable) { + ->bcc($this->bcc), function (MailableContract $mailable) { if ($this->locale) { $mailable->locale($this->locale); } diff --git a/src/Illuminate/Mail/SendQueuedMailable.php b/src/Illuminate/Mail/SendQueuedMailable.php index 2919809387274d9eef5e22deeeeef12d4e7c688c..1009789b4bf0a10404e4755cb591073f6f6f7acf 100644 --- a/src/Illuminate/Mail/SendQueuedMailable.php +++ b/src/Illuminate/Mail/SendQueuedMailable.php @@ -2,11 +2,15 @@ namespace Illuminate\Mail; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailable as MailableContract; -use Illuminate\Contracts\Mail\Mailer as MailerContract; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; class SendQueuedMailable { + use Queueable; + /** * The mailable message instance. * @@ -28,6 +32,13 @@ class SendQueuedMailable */ public $timeout; + /** + * Indicates if the job should be encrypted. + * + * @var bool + */ + public $shouldBeEncrypted = false; + /** * Create a new job instance. * @@ -39,17 +50,19 @@ class SendQueuedMailable $this->mailable = $mailable; $this->tries = property_exists($mailable, 'tries') ? $mailable->tries : null; $this->timeout = property_exists($mailable, 'timeout') ? $mailable->timeout : null; + $this->afterCommit = property_exists($mailable, 'afterCommit') ? $mailable->afterCommit : null; + $this->shouldBeEncrypted = $mailable instanceof ShouldBeEncrypted; } /** * Handle the queued job. * - * @param \Illuminate\Contracts\Mail\Mailer $mailer + * @param \Illuminate\Contracts\Mail\Factory $factory * @return void */ - public function handle(MailerContract $mailer) + public function handle(MailFactory $factory) { - $this->mailable->send($mailer); + $this->mailable->send($factory); } /** @@ -65,7 +78,7 @@ class SendQueuedMailable /** * Call the failed method on the mailable instance. * - * @param \Exception $e + * @param \Throwable $e * @return void */ public function failed($e) @@ -76,17 +89,17 @@ class SendQueuedMailable } /** - * Get the retry delay for the mailable object. + * Get the number of seconds before a released mailable will be available. * * @return mixed */ - public function retryAfter() + public function backoff() { - if (! method_exists($this->mailable, 'retryAfter') && ! isset($this->mailable->retryAfter)) { + if (! method_exists($this->mailable, 'backoff') && ! isset($this->mailable->backoff)) { return; } - return $this->mailable->retryAfter ?? $this->mailable->retryAfter(); + return $this->mailable->backoff ?? $this->mailable->backoff(); } /** diff --git a/src/Illuminate/Mail/Transport/ArrayTransport.php b/src/Illuminate/Mail/Transport/ArrayTransport.php index fbedec9560aa8820d63b2473b502a1cc5112548d..fe6fdf7dd2817e5af38b3d17d0c8010f4cebcf38 100644 --- a/src/Illuminate/Mail/Transport/ArrayTransport.php +++ b/src/Illuminate/Mail/Transport/ArrayTransport.php @@ -26,6 +26,8 @@ class ArrayTransport extends Transport /** * {@inheritdoc} + * + * @return int */ public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null) { diff --git a/src/Illuminate/Mail/Transport/LogTransport.php b/src/Illuminate/Mail/Transport/LogTransport.php index 43a2faa204ceecf6bce502d84b5eceb87937630b..21f1aae96df4d7fc46aaa8631aaac03331060c74 100644 --- a/src/Illuminate/Mail/Transport/LogTransport.php +++ b/src/Illuminate/Mail/Transport/LogTransport.php @@ -28,6 +28,8 @@ class LogTransport extends Transport /** * {@inheritdoc} + * + * @return int */ public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null) { diff --git a/src/Illuminate/Mail/Transport/MailgunTransport.php b/src/Illuminate/Mail/Transport/MailgunTransport.php index 195c000324647cbcf8dfbffa9da366c63b0beea7..71ceccfa07a9b65ce02ef1bdd48abb6751a3e78f 100644 --- a/src/Illuminate/Mail/Transport/MailgunTransport.php +++ b/src/Illuminate/Mail/Transport/MailgunTransport.php @@ -3,7 +3,9 @@ namespace Illuminate\Mail\Transport; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\GuzzleException; use Swift_Mime_SimpleMessage; +use Swift_TransportException; class MailgunTransport extends Transport { @@ -55,6 +57,8 @@ class MailgunTransport extends Transport /** * {@inheritdoc} + * + * @return int */ public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null) { @@ -66,15 +70,20 @@ class MailgunTransport extends Transport $message->setBcc([]); - $response = $this->client->request( - 'POST', - "https://{$this->endpoint}/v3/{$this->domain}/messages.mime", - $this->payload($message, $to) - ); + try { + $response = $this->client->request( + 'POST', + "https://{$this->endpoint}/v3/{$this->domain}/messages.mime", + $this->payload($message, $to) + ); + } catch (GuzzleException $e) { + throw new Swift_TransportException('Request to Mailgun API failed.', $e->getCode(), $e); + } - $message->getHeaders()->addTextHeader( - 'X-Mailgun-Message-ID', $this->getMessageId($response) - ); + $messageId = $this->getMessageId($response); + + $message->getHeaders()->addTextHeader('X-Message-ID', $messageId); + $message->getHeaders()->addTextHeader('X-Mailgun-Message-ID', $messageId); $message->setBcc($bcc); diff --git a/src/Illuminate/Mail/Transport/SesTransport.php b/src/Illuminate/Mail/Transport/SesTransport.php index 0dc8584a4edc6cb372080662ea22f46634bcba06..7dd81a227e3f19d416ed7f87c1618f72b629d378 100644 --- a/src/Illuminate/Mail/Transport/SesTransport.php +++ b/src/Illuminate/Mail/Transport/SesTransport.php @@ -2,8 +2,10 @@ namespace Illuminate\Mail\Transport; +use Aws\Exception\AwsException; use Aws\Ses\SesClient; use Swift_Mime_SimpleMessage; +use Swift_TransportException; class SesTransport extends Transport { @@ -36,23 +38,32 @@ class SesTransport extends Transport /** * {@inheritdoc} + * + * @return int */ public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null) { $this->beforeSendPerformed($message); - $result = $this->ses->sendRawEmail( - array_merge( - $this->options, [ - 'Source' => key($message->getSender() ?: $message->getFrom()), - 'RawMessage' => [ - 'Data' => $message->toString(), - ], - ] - ) - ); + try { + $result = $this->ses->sendRawEmail( + array_merge( + $this->options, [ + 'Source' => key($message->getSender() ?: $message->getFrom()), + 'RawMessage' => [ + 'Data' => $message->toString(), + ], + ] + ) + ); + } catch (AwsException $e) { + throw new Swift_TransportException('Request to AWS SES API failed.', $e->getCode(), $e); + } + + $messageId = $result->get('MessageId'); - $message->getHeaders()->addTextHeader('X-SES-Message-ID', $result->get('MessageId')); + $message->getHeaders()->addTextHeader('X-Message-ID', $messageId); + $message->getHeaders()->addTextHeader('X-SES-Message-ID', $messageId); $this->sendPerformed($message); diff --git a/src/Illuminate/Mail/Transport/Transport.php b/src/Illuminate/Mail/Transport/Transport.php index b26bff3ff57d9ac692268dac880364f952c7c35e..62b44957cf0c9d1aedf385be32d0f210cbc50e32 100644 --- a/src/Illuminate/Mail/Transport/Transport.php +++ b/src/Illuminate/Mail/Transport/Transport.php @@ -18,6 +18,8 @@ abstract class Transport implements Swift_Transport /** * {@inheritdoc} + * + * @return bool */ public function isStarted() { @@ -42,6 +44,8 @@ abstract class Transport implements Swift_Transport /** * {@inheritdoc} + * + * @return bool */ public function ping() { diff --git a/src/Illuminate/Mail/TransportManager.php b/src/Illuminate/Mail/TransportManager.php deleted file mode 100644 index e711bc5d7e6361c0ddd0507e7588cb5768bc8a1e..0000000000000000000000000000000000000000 --- a/src/Illuminate/Mail/TransportManager.php +++ /dev/null @@ -1,218 +0,0 @@ -<?php - -namespace Illuminate\Mail; - -use Aws\Ses\SesClient; -use GuzzleHttp\Client as HttpClient; -use Illuminate\Log\LogManager; -use Illuminate\Mail\Transport\ArrayTransport; -use Illuminate\Mail\Transport\LogTransport; -use Illuminate\Mail\Transport\MailgunTransport; -use Illuminate\Mail\Transport\SesTransport; -use Illuminate\Support\Arr; -use Illuminate\Support\Manager; -use Postmark\ThrowExceptionOnFailurePlugin; -use Postmark\Transport as PostmarkTransport; -use Psr\Log\LoggerInterface; -use Swift_SendmailTransport as SendmailTransport; -use Swift_SmtpTransport as SmtpTransport; - -class TransportManager extends Manager -{ - /** - * Create an instance of the SMTP Swift Transport driver. - * - * @return \Swift_SmtpTransport - */ - protected function createSmtpDriver() - { - $config = $this->config->get('mail'); - - // The Swift SMTP transport instance will allow us to use any SMTP backend - // for delivering mail such as Sendgrid, Amazon SES, or a custom server - // a developer has available. We will just pass this configured host. - $transport = new SmtpTransport($config['host'], $config['port']); - - if (! empty($config['encryption'])) { - $transport->setEncryption($config['encryption']); - } - - // Once we have the transport we will check for the presence of a username - // and password. If we have it we will set the credentials on the Swift - // transporter instance so that we'll properly authenticate delivery. - if (isset($config['username'])) { - $transport->setUsername($config['username']); - - $transport->setPassword($config['password']); - } - - return $this->configureSmtpDriver($transport, $config); - } - - /** - * Configure the additional SMTP driver options. - * - * @param \Swift_SmtpTransport $transport - * @param array $config - * @return \Swift_SmtpTransport - */ - protected function configureSmtpDriver($transport, $config) - { - if (isset($config['stream'])) { - $transport->setStreamOptions($config['stream']); - } - - if (isset($config['source_ip'])) { - $transport->setSourceIp($config['source_ip']); - } - - if (isset($config['local_domain'])) { - $transport->setLocalDomain($config['local_domain']); - } - - return $transport; - } - - /** - * Create an instance of the Sendmail Swift Transport driver. - * - * @return \Swift_SendmailTransport - */ - protected function createSendmailDriver() - { - return new SendmailTransport($this->config->get('mail.sendmail')); - } - - /** - * Create an instance of the Amazon SES Swift Transport driver. - * - * @return \Illuminate\Mail\Transport\SesTransport - */ - protected function createSesDriver() - { - $config = array_merge($this->config->get('services.ses', []), [ - 'version' => 'latest', 'service' => 'email', - ]); - - return new SesTransport( - new SesClient($this->addSesCredentials($config)), - $config['options'] ?? [] - ); - } - - /** - * Add the SES credentials to the configuration array. - * - * @param array $config - * @return array - */ - protected function addSesCredentials(array $config) - { - if (! empty($config['key']) && ! empty($config['secret'])) { - $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); - } - - return $config; - } - - /** - * Create an instance of the Mail Swift Transport driver. - * - * @return \Swift_SendmailTransport - */ - protected function createMailDriver() - { - return new SendmailTransport; - } - - /** - * Create an instance of the Mailgun Swift Transport driver. - * - * @return \Illuminate\Mail\Transport\MailgunTransport - */ - protected function createMailgunDriver() - { - $config = $this->config->get('services.mailgun', []); - - return new MailgunTransport( - $this->guzzle($config), - $config['secret'], - $config['domain'], - $config['endpoint'] ?? null - ); - } - - /** - * Create an instance of the Postmark Swift Transport driver. - * - * @return \Swift_Transport - */ - protected function createPostmarkDriver() - { - return tap(new PostmarkTransport( - $this->config->get('services.postmark.token') - ), function ($transport) { - $transport->registerPlugin(new ThrowExceptionOnFailurePlugin()); - }); - } - - /** - * Create an instance of the Log Swift Transport driver. - * - * @return \Illuminate\Mail\Transport\LogTransport - */ - protected function createLogDriver() - { - $logger = $this->container->make(LoggerInterface::class); - - if ($logger instanceof LogManager) { - $logger = $logger->channel($this->config->get('mail.log_channel')); - } - - return new LogTransport($logger); - } - - /** - * Create an instance of the Array Swift Transport Driver. - * - * @return \Illuminate\Mail\Transport\ArrayTransport - */ - protected function createArrayDriver() - { - return new ArrayTransport; - } - - /** - * Get a fresh Guzzle HTTP client instance. - * - * @param array $config - * @return \GuzzleHttp\Client - */ - protected function guzzle($config) - { - return new HttpClient(Arr::add( - $config['guzzle'] ?? [], 'connect_timeout', 60 - )); - } - - /** - * Get the default mail driver name. - * - * @return string - */ - public function getDefaultDriver() - { - return $this->config->get('mail.driver'); - } - - /** - * Set the default mail driver name. - * - * @param string $name - * @return void - */ - public function setDefaultDriver($name) - { - $this->config->set('mail.driver', $name); - } -} diff --git a/src/Illuminate/Mail/composer.json b/src/Illuminate/Mail/composer.json index dfe6100d24c79808995b4fb8837e6bec04885c1d..cfddcb3a3dda8fe74a9e11ea2d763296e992137e 100755 --- a/src/Illuminate/Mail/composer.json +++ b/src/Illuminate/Mail/composer.json @@ -14,15 +14,17 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "league/commonmark": "^1.3", - "psr/log": "^1.0", - "swiftmailer/swiftmailer": "^6.0", - "tijsverkoyen/css-to-inline-styles": "^2.2.1" + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "league/commonmark": "^1.3|^2.0.2", + "psr/log": "^1.0|^2.0", + "swiftmailer/swiftmailer": "^6.3", + "tijsverkoyen/css-to-inline-styles": "^2.2.2" }, "autoload": { "psr-4": { @@ -31,12 +33,12 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "aws/aws-sdk-php": "Required to use the SES mail driver (^3.155).", - "guzzlehttp/guzzle": "Required to use the Mailgun mail driver (^6.3.1|^7.0.1).", + "aws/aws-sdk-php": "Required to use the SES mail driver (^3.198.1).", + "guzzlehttp/guzzle": "Required to use the Mailgun mail driver (^6.5.5|^7.0.1).", "wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)." }, "config": { diff --git a/src/Illuminate/Mail/resources/views/html/button.blade.php b/src/Illuminate/Mail/resources/views/html/button.blade.php index d760329c176133d263a5fc8baceb07966be9f468..e74fe55a716c9355829df5a16ddbb01584b77033 100644 --- a/src/Illuminate/Mail/resources/views/html/button.blade.php +++ b/src/Illuminate/Mail/resources/views/html/button.blade.php @@ -7,7 +7,7 @@ <table border="0" cellpadding="0" cellspacing="0" role="presentation"> <tr> <td> -<a href="{{ $url }}" class="button button-{{ $color ?? 'primary' }}" target="_blank">{{ $slot }}</a> +<a href="{{ $url }}" class="button button-{{ $color ?? 'primary' }}" target="_blank" rel="noopener">{{ $slot }}</a> </td> </tr> </table> diff --git a/src/Illuminate/Mail/resources/views/html/header.blade.php b/src/Illuminate/Mail/resources/views/html/header.blade.php index 79971d9e8abfc87d12010f15afe9bf169b100180..fa1875caa22159b9df6493600a03d0dfa75a0b64 100644 --- a/src/Illuminate/Mail/resources/views/html/header.blade.php +++ b/src/Illuminate/Mail/resources/views/html/header.blade.php @@ -1,7 +1,11 @@ <tr> <td class="header"> -<a href="{{ $url }}"> +<a href="{{ $url }}" style="display: inline-block;"> +@if (trim($slot) === 'Laravel') +<img src="https://laravel.com/img/notification-logo.png" class="logo" alt="Laravel Logo"> +@else {{ $slot }} +@endif </a> </td> </tr> diff --git a/src/Illuminate/Mail/resources/views/html/layout.blade.php b/src/Illuminate/Mail/resources/views/html/layout.blade.php index 02a54e2da78015d9ea8d07a74f70ea10dc9a4051..21d349b39ea76949a0f64a51f3ea0b58f4455fc0 100644 --- a/src/Illuminate/Mail/resources/views/html/layout.blade.php +++ b/src/Illuminate/Mail/resources/views/html/layout.blade.php @@ -3,8 +3,8 @@ <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> -</head> -<body> +<meta name="color-scheme" content="light"> +<meta name="supported-color-schemes" content="light"> <style> @media only screen and (max-width: 600px) { .inner-body { @@ -22,6 +22,8 @@ width: 100% !important; } } </style> +</head> +<body> <table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"> <tr> diff --git a/src/Illuminate/Mail/resources/views/html/promotion.blade.php b/src/Illuminate/Mail/resources/views/html/promotion.blade.php deleted file mode 100644 index 8fef44c97be5c2fe0a8fe2b14d4672df36037158..0000000000000000000000000000000000000000 --- a/src/Illuminate/Mail/resources/views/html/promotion.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -<table class="promotion" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation"> -<tr> -<td align="center"> -{{ Illuminate\Mail\Markdown::parse($slot) }} -</td> -</tr> -</table> diff --git a/src/Illuminate/Mail/resources/views/html/promotion/button.blade.php b/src/Illuminate/Mail/resources/views/html/promotion/button.blade.php deleted file mode 100644 index 1dcb2ee468485b0ec63216c75f07538564d77197..0000000000000000000000000000000000000000 --- a/src/Illuminate/Mail/resources/views/html/promotion/button.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"> - <tr> - <td align="center"> - <table border="0" cellpadding="0" cellspacing="0" role="presentation"> - <tr> - <td> - <a href="{{ $url }}" class="button button-green" target="_blank">{{ $slot }}</a> - </td> - </tr> - </table> - </td> - </tr> -</table> diff --git a/src/Illuminate/Mail/resources/views/html/themes/default.css b/src/Illuminate/Mail/resources/views/html/themes/default.css index 37c3edd9ea1f75a1510bf32349e19ab328383c2c..2483b11685a34beff4c255f8640074001d4558b3 100644 --- a/src/Illuminate/Mail/resources/views/html/themes/default.css +++ b/src/Illuminate/Mail/resources/views/html/themes/default.css @@ -2,25 +2,21 @@ body, body *:not(html):not(style):not(br):not(tr):not(code) { + box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - box-sizing: border-box; + position: relative; } body { - background-color: #f8fafc; - color: #74787e; + -webkit-text-size-adjust: none; + background-color: #ffffff; + color: #718096; height: 100%; - hyphens: auto; line-height: 1.4; margin: 0; - -moz-hyphens: auto; - -ms-word-break: break-all; + padding: 0; width: 100% !important; - -webkit-hyphens: auto; - -webkit-text-size-adjust: none; - word-break: break-all; - word-break: break-word; } p, @@ -43,14 +39,13 @@ a img { h1 { color: #3d4852; - font-size: 19px; + font-size: 18px; font-weight: bold; margin-top: 0; text-align: left; } h2 { - color: #3d4852; font-size: 16px; font-weight: bold; margin-top: 0; @@ -58,7 +53,6 @@ h2 { } h3 { - color: #3d4852; font-size: 14px; font-weight: bold; margin-top: 0; @@ -66,7 +60,6 @@ h3 { } p { - color: #3d4852; font-size: 16px; line-height: 1.5em; margin-top: 0; @@ -84,22 +77,22 @@ img { /* Layout */ .wrapper { - background-color: #f8fafc; - margin: 0; - padding: 0; - width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; -} - -.content { + background-color: #edf2f7; margin: 0; padding: 0; width: 100%; +} + +.content { -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; + margin: 0; + padding: 0; + width: 100%; } /* Header */ @@ -110,154 +103,177 @@ img { } .header a { - color: #bbbfc3; + color: #3d4852; font-size: 19px; font-weight: bold; text-decoration: none; - text-shadow: 0 1px 0 white; +} + +/* Logo */ + +.logo { + height: 75px; + max-height: 75px; + width: 75px; } /* Body */ .body { - background-color: #ffffff; - border-bottom: 1px solid #edeff2; - border-top: 1px solid #edeff2; - margin: 0; - padding: 0; - width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; + background-color: #edf2f7; + border-bottom: 1px solid #edf2f7; + border-top: 1px solid #edf2f7; + margin: 0; + padding: 0; + width: 100%; } .inner-body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; background-color: #ffffff; + border-color: #e8e5ef; + border-radius: 2px; + border-width: 1px; + box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015); margin: 0 auto; padding: 0; width: 570px; - -premailer-cellpadding: 0; - -premailer-cellspacing: 0; - -premailer-width: 570px; } /* Subcopy */ .subcopy { - border-top: 1px solid #edeff2; + border-top: 1px solid #e8e5ef; margin-top: 25px; padding-top: 25px; } .subcopy p { - font-size: 12px; + font-size: 14px; } /* Footer */ .footer { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; margin: 0 auto; padding: 0; text-align: center; width: 570px; - -premailer-cellpadding: 0; - -premailer-cellspacing: 0; - -premailer-width: 570px; } .footer p { - color: #aeaeae; + color: #b0adc5; font-size: 12px; text-align: center; } +.footer a { + color: #b0adc5; + text-decoration: underline; +} + /* Tables */ .table table { - margin: 30px auto; - width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; + margin: 30px auto; + width: 100%; } .table th { border-bottom: 1px solid #edeff2; - padding-bottom: 8px; margin: 0; + padding-bottom: 8px; } .table td { color: #74787e; font-size: 15px; line-height: 18px; - padding: 10px 0; margin: 0; + padding: 10px 0; } .content-cell { - padding: 35px; + max-width: 100vw; + padding: 32px; } /* Buttons */ .action { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; margin: 30px auto; padding: 0; text-align: center; width: 100%; - -premailer-cellpadding: 0; - -premailer-cellspacing: 0; - -premailer-width: 100%; } .button { - border-radius: 3px; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); + -webkit-text-size-adjust: none; + border-radius: 4px; color: #fff; display: inline-block; + overflow: hidden; text-decoration: none; - -webkit-text-size-adjust: none; } .button-blue, .button-primary { - background-color: #3490dc; - border-top: 10px solid #3490dc; - border-right: 18px solid #3490dc; - border-bottom: 10px solid #3490dc; - border-left: 18px solid #3490dc; + background-color: #2d3748; + border-bottom: 8px solid #2d3748; + border-left: 18px solid #2d3748; + border-right: 18px solid #2d3748; + border-top: 8px solid #2d3748; } .button-green, .button-success { - background-color: #38c172; - border-top: 10px solid #38c172; - border-right: 18px solid #38c172; - border-bottom: 10px solid #38c172; - border-left: 18px solid #38c172; + background-color: #48bb78; + border-bottom: 8px solid #48bb78; + border-left: 18px solid #48bb78; + border-right: 18px solid #48bb78; + border-top: 8px solid #48bb78; } .button-red, .button-error { - background-color: #e3342f; - border-top: 10px solid #e3342f; - border-right: 18px solid #e3342f; - border-bottom: 10px solid #e3342f; - border-left: 18px solid #e3342f; + background-color: #e53e3e; + border-bottom: 8px solid #e53e3e; + border-left: 18px solid #e53e3e; + border-right: 18px solid #e53e3e; + border-top: 8px solid #e53e3e; } /* Panels */ .panel { - margin: 0 0 21px; + border-left: #2d3748 solid 4px; + margin: 21px 0; } .panel-content { - background-color: #f1f5f8; + background-color: #edf2f7; + color: #718096; padding: 16px; } +.panel-content p { + color: #718096; +} + .panel-item { padding: 0; } @@ -267,30 +283,6 @@ img { padding-bottom: 0; } -/* Promotions */ - -.promotion { - background-color: #ffffff; - border: 2px dashed #9ba2ab; - margin: 0; - margin-bottom: 25px; - margin-top: 25px; - padding: 24px; - width: 100%; - -premailer-cellpadding: 0; - -premailer-cellspacing: 0; - -premailer-width: 100%; -} - -.promotion h1 { - text-align: center; -} - -.promotion p { - font-size: 15px; - text-align: center; -} - /* Utilities */ .break-all { diff --git a/src/Illuminate/Mail/resources/views/text/promotion.blade.php b/src/Illuminate/Mail/resources/views/text/promotion.blade.php deleted file mode 100644 index 3338f620e42f9e0b2bb4829eb7795681bd7b2523..0000000000000000000000000000000000000000 --- a/src/Illuminate/Mail/resources/views/text/promotion.blade.php +++ /dev/null @@ -1 +0,0 @@ -{{ $slot }} diff --git a/src/Illuminate/Mail/resources/views/text/promotion/button.blade.php b/src/Illuminate/Mail/resources/views/text/promotion/button.blade.php deleted file mode 100644 index aaa3e5754446d837da5bcf4977505f00f25171e9..0000000000000000000000000000000000000000 --- a/src/Illuminate/Mail/resources/views/text/promotion/button.blade.php +++ /dev/null @@ -1 +0,0 @@ -[{{ $slot }}]({{ $url }}) diff --git a/src/Illuminate/Notifications/AnonymousNotifiable.php b/src/Illuminate/Notifications/AnonymousNotifiable.php index eab959b7c5645efb41e3817c342160926ba141f0..aa4d7bbc7b42ac199cdd869b49fa1dc0cd83fd15 100644 --- a/src/Illuminate/Notifications/AnonymousNotifiable.php +++ b/src/Illuminate/Notifications/AnonymousNotifiable.php @@ -20,6 +20,8 @@ class AnonymousNotifiable * @param string $channel * @param mixed $route * @return $this + * + * @throws \InvalidArgumentException */ public function route($channel, $route) { diff --git a/src/Illuminate/Notifications/ChannelManager.php b/src/Illuminate/Notifications/ChannelManager.php index d2344ab68acc57c55ff990271e18ae718b1d12ac..8eb9c251024d92934ac40fcb9144b1dd995a8e33 100644 --- a/src/Illuminate/Notifications/ChannelManager.php +++ b/src/Illuminate/Notifications/ChannelManager.php @@ -34,7 +34,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr */ public function send($notifiables, $notification) { - return (new NotificationSender( + (new NotificationSender( $this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale) )->send($notifiables, $notification); } @@ -49,7 +49,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr */ public function sendNow($notifiables, $notification, array $channels = null) { - return (new NotificationSender( + (new NotificationSender( $this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale) )->sendNow($notifiables, $notification, $channels); } diff --git a/src/Illuminate/Notifications/Channels/BroadcastChannel.php b/src/Illuminate/Notifications/Channels/BroadcastChannel.php index d281b9b1383127bf6650c5d7d12cb31cc88336d7..1389f49c6ac8d0f15057201678d597f69afeaeac 100644 --- a/src/Illuminate/Notifications/Channels/BroadcastChannel.php +++ b/src/Illuminate/Notifications/Channels/BroadcastChannel.php @@ -18,7 +18,7 @@ class BroadcastChannel protected $events; /** - * Create a new database channel. + * Create a new broadcast channel. * * @param \Illuminate\Contracts\Events\Dispatcher $events * @return void diff --git a/src/Illuminate/Notifications/Channels/DatabaseChannel.php b/src/Illuminate/Notifications/Channels/DatabaseChannel.php index bd8af623144f5690c2ba7aab2cddfd90632fb912..8b3167b01508c06c9f7bd6e5e7cd7de3c960cb7a 100644 --- a/src/Illuminate/Notifications/Channels/DatabaseChannel.php +++ b/src/Illuminate/Notifications/Channels/DatabaseChannel.php @@ -21,6 +21,25 @@ class DatabaseChannel ); } + /** + * Build an array payload for the DatabaseNotification Model. + * + * @param mixed $notifiable + * @param \Illuminate\Notifications\Notification $notification + * @return array + */ + protected function buildPayload($notifiable, Notification $notification) + { + return [ + 'id' => $notification->id, + 'type' => method_exists($notification, 'databaseType') + ? $notification->databaseType($notifiable) + : get_class($notification), + 'data' => $this->getData($notifiable, $notification), + 'read_at' => null, + ]; + } + /** * Get the data for the notification. * @@ -43,21 +62,4 @@ class DatabaseChannel throw new RuntimeException('Notification is missing toDatabase / toArray method.'); } - - /** - * Build an array payload for the DatabaseNotification Model. - * - * @param mixed $notifiable - * @param \Illuminate\Notifications\Notification $notification - * @return array - */ - protected function buildPayload($notifiable, Notification $notification) - { - return [ - 'id' => $notification->id, - 'type' => get_class($notification), - 'data' => $this->getData($notifiable, $notification), - 'read_at' => null, - ]; - } } diff --git a/src/Illuminate/Notifications/Channels/MailChannel.php b/src/Illuminate/Notifications/Channels/MailChannel.php index d28241ac40a6637b7b183135f8a47aa4e6627a5c..2a30bcdacc84c40bd2b34da1a5be343ac1638aea 100644 --- a/src/Illuminate/Notifications/Channels/MailChannel.php +++ b/src/Illuminate/Notifications/Channels/MailChannel.php @@ -2,8 +2,8 @@ namespace Illuminate\Notifications\Channels; +use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailable; -use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Markdown; use Illuminate\Notifications\Notification; @@ -15,7 +15,7 @@ class MailChannel /** * The mailer implementation. * - * @var \Illuminate\Contracts\Mail\Mailer + * @var \Illuminate\Contracts\Mail\Factory */ protected $mailer; @@ -29,11 +29,11 @@ class MailChannel /** * Create a new mail channel instance. * - * @param \Illuminate\Contracts\Mail\Mailer $mailer + * @param \Illuminate\Contracts\Mail\Factory $mailer * @param \Illuminate\Mail\Markdown $markdown * @return void */ - public function __construct(Mailer $mailer, Markdown $markdown) + public function __construct(MailFactory $mailer, Markdown $markdown) { $this->mailer = $mailer; $this->markdown = $markdown; @@ -59,7 +59,7 @@ class MailChannel return $message->send($this->mailer); } - $this->mailer->send( + $this->mailer->mailer($message->mailer ?? null)->send( $this->buildView($message), array_merge($message->data(), $this->additionalMessageData($notification)), $this->messageBuilder($notifiable, $notification, $message) @@ -115,7 +115,8 @@ class MailChannel '__laravel_notification_id' => $notification->id, '__laravel_notification' => get_class($notification), '__laravel_notification_queued' => in_array( - ShouldQueue::class, class_implements($notification) + ShouldQueue::class, + class_implements($notification) ), ]; } diff --git a/src/Illuminate/Notifications/DatabaseNotification.php b/src/Illuminate/Notifications/DatabaseNotification.php index 0dfc7e53015c6e92221d345b8c664bd5bb05e8cc..14bc9d659f9702a4008297f8feaea04ef4d1dea2 100644 --- a/src/Illuminate/Notifications/DatabaseNotification.php +++ b/src/Illuminate/Notifications/DatabaseNotification.php @@ -2,6 +2,7 @@ namespace Illuminate\Notifications; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; class DatabaseNotification extends Model @@ -98,6 +99,28 @@ class DatabaseNotification extends Model return $this->read_at === null; } + /** + * Scope a query to only include read notifications. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeRead(Builder $query) + { + return $query->whereNotNull('read_at'); + } + + /** + * Scope a query to only include unread notifications. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeUnread(Builder $query) + { + return $query->whereNull('read_at'); + } + /** * Create a new database notification collection instance. * diff --git a/src/Illuminate/Notifications/Events/BroadcastNotificationCreated.php b/src/Illuminate/Notifications/Events/BroadcastNotificationCreated.php index 77498ea3987490e23be8c5f2d454c25ce004685f..24958852758b174fe1b44357fb925a9ec8cb9585 100644 --- a/src/Illuminate/Notifications/Events/BroadcastNotificationCreated.php +++ b/src/Illuminate/Notifications/Events/BroadcastNotificationCreated.php @@ -92,6 +92,10 @@ class BroadcastNotificationCreated implements ShouldBroadcast */ public function broadcastWith() { + if (method_exists($this->notification, 'broadcastWith')) { + return $this->notification->broadcastWith(); + } + return array_merge($this->data, [ 'id' => $this->notification->id, 'type' => $this->broadcastType(), diff --git a/src/Illuminate/Notifications/HasDatabaseNotifications.php b/src/Illuminate/Notifications/HasDatabaseNotifications.php index 981d8e5525832e42203c523da876cb413ffc71ec..5f999da9a34d45421dec48438e3e794b3e28fbad 100644 --- a/src/Illuminate/Notifications/HasDatabaseNotifications.php +++ b/src/Illuminate/Notifications/HasDatabaseNotifications.php @@ -21,7 +21,7 @@ trait HasDatabaseNotifications */ public function readNotifications() { - return $this->notifications()->whereNotNull('read_at'); + return $this->notifications()->read(); } /** @@ -31,6 +31,6 @@ trait HasDatabaseNotifications */ public function unreadNotifications() { - return $this->notifications()->whereNull('read_at'); + return $this->notifications()->unread(); } } diff --git a/src/Illuminate/Notifications/Messages/MailMessage.php b/src/Illuminate/Notifications/Messages/MailMessage.php index 08ee2f1f7433c1857920fa560f2a0c5b5be8b790..94342f30b2bce7156e42f35fc30f4ffebe48550c 100644 --- a/src/Illuminate/Notifications/Messages/MailMessage.php +++ b/src/Illuminate/Notifications/Messages/MailMessage.php @@ -6,10 +6,12 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Renderable; use Illuminate\Mail\Markdown; -use Traversable; +use Illuminate\Support\Traits\Conditionable; class MailMessage extends SimpleMessage implements Renderable { + use Conditionable; + /** * The view to be rendered. * @@ -297,9 +299,7 @@ class MailMessage extends SimpleMessage implements Renderable */ protected function arrayOfAddresses($address) { - return is_array($address) || - $address instanceof Arrayable || - $address instanceof Traversable; + return is_iterable($address) || $address instanceof Arrayable; } /** @@ -315,9 +315,10 @@ class MailMessage extends SimpleMessage implements Renderable ); } - return Container::getInstance() - ->make(Markdown::class) - ->render($this->markdown, $this->data()); + $markdown = Container::getInstance()->make(Markdown::class); + + return $markdown->theme($this->theme ?: $markdown->getTheme()) + ->render($this->markdown, $this->data()); } /** diff --git a/src/Illuminate/Notifications/Messages/SimpleMessage.php b/src/Illuminate/Notifications/Messages/SimpleMessage.php index e506bc01e56bcd0b3ee9e2ed275896cd91139237..7dab7e452e0ea7d72ffbcfe9f5ac72ab4c45996d 100644 --- a/src/Illuminate/Notifications/Messages/SimpleMessage.php +++ b/src/Illuminate/Notifications/Messages/SimpleMessage.php @@ -63,6 +63,13 @@ class SimpleMessage */ public $actionUrl; + /** + * The name of the mailer that should send the notification. + * + * @var string + */ + public $mailer; + /** * Indicate that the notification gives information about a successful operation. * @@ -150,6 +157,21 @@ class SimpleMessage return $this->with($line); } + /** + * Add lines of text to the notification. + * + * @param iterable $lines + * @return $this + */ + public function lines($lines) + { + foreach ($lines as $line) { + $this->line($line); + } + + return $this; + } + /** * Add a line of text to the notification. * @@ -185,7 +207,7 @@ class SimpleMessage return implode(' ', array_map('trim', $line)); } - return trim(implode(' ', array_map('trim', preg_split('/\\r\\n|\\r|\\n/', $line)))); + return trim(implode(' ', array_map('trim', preg_split('/\\r\\n|\\r|\\n/', $line ?? '')))); } /** @@ -203,6 +225,19 @@ class SimpleMessage return $this; } + /** + * Set the name of the mailer that should send the notification. + * + * @param string $mailer + * @return $this + */ + public function mailer($mailer) + { + $this->mailer = $mailer; + + return $this; + } + /** * Get an array representation of the message. * @@ -219,7 +254,7 @@ class SimpleMessage 'outroLines' => $this->outroLines, 'actionText' => $this->actionText, 'actionUrl' => $this->actionUrl, - 'displayableActionUrl' => str_replace(['mailto:', 'tel:'], '', $this->actionUrl), + 'displayableActionUrl' => str_replace(['mailto:', 'tel:'], '', $this->actionUrl ?? ''), ]; } } diff --git a/src/Illuminate/Notifications/NotificationSender.php b/src/Illuminate/Notifications/NotificationSender.php index 19b9a7de15cd9b83cb1355f864623347b6d65604..c7b67ecc3af1d61aaef32f1f35c13baf0e738bd5 100644 --- a/src/Illuminate/Notifications/NotificationSender.php +++ b/src/Illuminate/Notifications/NotificationSender.php @@ -76,7 +76,7 @@ class NotificationSender return $this->queueNotification($notifiables, $notification); } - return $this->sendNow($notifiables, $notification); + $this->sendNow($notifiables, $notification); } /** @@ -162,6 +162,11 @@ class NotificationSender */ protected function shouldSendNotification($notifiable, $notification, $channel) { + if (method_exists($notification, 'shouldSend') && + $notification->shouldSend($notifiable, $channel) === false) { + return false; + } + return $this->events->until( new NotificationSending($notifiable, $notification, $channel) ) !== false; @@ -192,11 +197,20 @@ class NotificationSender $notification->locale = $this->locale; } + $queue = $notification->queue; + + if (method_exists($notification, 'viaQueues')) { + $queue = $notification->viaQueues()[$channel] ?? null; + } + $this->bus->dispatch( (new SendQueuedNotifications($notifiable, $notification, [$channel])) ->onConnection($notification->connection) - ->onQueue($notification->queue) - ->delay($notification->delay) + ->onQueue($queue) + ->delay(is_array($notification->delay) ? + ($notification->delay[$channel] ?? null) + : $notification->delay + ) ->through( array_merge( method_exists($notification, 'middleware') ? $notification->middleware() : [], diff --git a/src/Illuminate/Notifications/SendQueuedNotifications.php b/src/Illuminate/Notifications/SendQueuedNotifications.php index 62daeb5bfc3a2e3fd6adc90a26cd81a94c3989ac..d83c8906e36604604a3302b8432511ce8cc8cdc1 100644 --- a/src/Illuminate/Notifications/SendQueuedNotifications.php +++ b/src/Illuminate/Notifications/SendQueuedNotifications.php @@ -3,9 +3,13 @@ namespace Illuminate\Notifications; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; class SendQueuedNotifications implements ShouldQueue { @@ -46,10 +50,17 @@ class SendQueuedNotifications implements ShouldQueue */ public $timeout; + /** + * Indicates if the job should be encrypted. + * + * @var bool + */ + public $shouldBeEncrypted = false; + /** * Create a new job instance. * - * @param \Illuminate\Support\Collection $notifiables + * @param \Illuminate\Notifications\Notifiable|\Illuminate\Support\Collection $notifiables * @param \Illuminate\Notifications\Notification $notification * @param array|null $channels * @return void @@ -57,10 +68,29 @@ class SendQueuedNotifications implements ShouldQueue public function __construct($notifiables, $notification, array $channels = null) { $this->channels = $channels; - $this->notifiables = $notifiables; $this->notification = $notification; + $this->notifiables = $this->wrapNotifiables($notifiables); $this->tries = property_exists($notification, 'tries') ? $notification->tries : null; $this->timeout = property_exists($notification, 'timeout') ? $notification->timeout : null; + $this->afterCommit = property_exists($notification, 'afterCommit') ? $notification->afterCommit : null; + $this->shouldBeEncrypted = $notification instanceof ShouldBeEncrypted; + } + + /** + * Wrap the notifiable(s) in a collection. + * + * @param \Illuminate\Notifications\Notifiable|\Illuminate\Support\Collection $notifiables + * @return \Illuminate\Support\Collection + */ + protected function wrapNotifiables($notifiables) + { + if ($notifiables instanceof Collection) { + return $notifiables; + } elseif ($notifiables instanceof Model) { + return EloquentCollection::wrap($notifiables); + } + + return Collection::wrap($notifiables); } /** @@ -87,7 +117,7 @@ class SendQueuedNotifications implements ShouldQueue /** * Call the failed method on the notification instance. * - * @param \Exception $e + * @param \Throwable $e * @return void */ public function failed($e) @@ -98,17 +128,17 @@ class SendQueuedNotifications implements ShouldQueue } /** - * Get the retry delay for the notification. + * Get the number of seconds before a released notification will be available. * * @return mixed */ - public function retryAfter() + public function backoff() { - if (! method_exists($this->notification, 'retryAfter') && ! isset($this->notification->retryAfter)) { + if (! method_exists($this->notification, 'backoff') && ! isset($this->notification->backoff)) { return; } - return $this->notification->retryAfter ?? $this->notification->retryAfter(); + return $this->notification->backoff ?? $this->notification->backoff(); } /** @@ -118,11 +148,11 @@ class SendQueuedNotifications implements ShouldQueue */ public function retryUntil() { - if (! method_exists($this->notification, 'retryUntil') && ! isset($this->notification->timeoutAt)) { + if (! method_exists($this->notification, 'retryUntil') && ! isset($this->notification->retryUntil)) { return; } - return $this->notification->timeoutAt ?? $this->notification->retryUntil(); + return $this->notification->retryUntil ?? $this->notification->retryUntil(); } /** diff --git a/src/Illuminate/Notifications/composer.json b/src/Illuminate/Notifications/composer.json index 8a96b664690e50e9b4ef4f101418d187a7462054..1bc673a22f8c68b0622e5ac68f7119d2111486df 100644 --- a/src/Illuminate/Notifications/composer.json +++ b/src/Illuminate/Notifications/composer.json @@ -14,15 +14,16 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/broadcasting": "^6.0", - "illuminate/bus": "^6.0", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/filesystem": "^6.0", - "illuminate/mail": "^6.0", - "illuminate/queue": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/broadcasting": "^8.0", + "illuminate/bus": "^8.0", + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/filesystem": "^8.0", + "illuminate/mail": "^8.0", + "illuminate/queue": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -31,11 +32,11 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/database": "Required to use the database transport (^6.0)." + "illuminate/database": "Required to use the database transport (^8.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Notifications/resources/views/email.blade.php b/src/Illuminate/Notifications/resources/views/email.blade.php index e7a56b461d94ca2112883849935b1f836f9b588f..bcf39f0a15b9ae4c8a7422f34f4cd07d8ec93e0f 100644 --- a/src/Illuminate/Notifications/resources/views/email.blade.php +++ b/src/Illuminate/Notifications/resources/views/email.blade.php @@ -51,7 +51,7 @@ @isset($actionText) @slot('subcopy') @lang( - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". 'into your web browser:', [ 'actionText' => $actionText, diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php new file mode 100644 index 0000000000000000000000000000000000000000..12344850b958c7e893cf51f110b9505b57b33e8f --- /dev/null +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -0,0 +1,676 @@ +<?php + +namespace Illuminate\Pagination; + +use ArrayAccess; +use Closure; +use Exception; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\ForwardsCalls; +use Illuminate\Support\Traits\Tappable; + +/** + * @mixin \Illuminate\Support\Collection + */ +abstract class AbstractCursorPaginator implements Htmlable +{ + use ForwardsCalls, Tappable; + + /** + * All of the items being paginated. + * + * @var \Illuminate\Support\Collection + */ + protected $items; + + /** + * The number of items to be shown per page. + * + * @var int + */ + protected $perPage; + + /** + * The base path to assign to all URLs. + * + * @var string + */ + protected $path = '/'; + + /** + * The query parameters to add to all URLs. + * + * @var array + */ + protected $query = []; + + /** + * The URL fragment to add to all URLs. + * + * @var string|null + */ + protected $fragment; + + /** + * The cursor string variable used to store the page. + * + * @var string + */ + protected $cursorName = 'cursor'; + + /** + * The current cursor. + * + * @var \Illuminate\Pagination\Cursor|null + */ + protected $cursor; + + /** + * The paginator parameters for the cursor. + * + * @var array + */ + protected $parameters; + + /** + * The paginator options. + * + * @var array + */ + protected $options; + + /** + * The current cursor resolver callback. + * + * @var \Closure + */ + protected static $currentCursorResolver; + + /** + * Get the URL for a given cursor. + * + * @param \Illuminate\Pagination\Cursor|null $cursor + * @return string + */ + public function url($cursor) + { + // If we have any extra query string key / value pairs that need to be added + // onto the URL, we will put them in query string form and then attach it + // to the URL. This allows for extra information like sortings storage. + $parameters = is_null($cursor) ? [] : [$this->cursorName => $cursor->encode()]; + + if (count($this->query) > 0) { + $parameters = array_merge($this->query, $parameters); + } + + return $this->path() + .(Str::contains($this->path(), '?') ? '&' : '?') + .Arr::query($parameters) + .$this->buildFragment(); + } + + /** + * Get the URL for the previous page. + * + * @return string|null + */ + public function previousPageUrl() + { + if (is_null($previousCursor = $this->previousCursor())) { + return null; + } + + return $this->url($previousCursor); + } + + /** + * The URL for the next page, or null. + * + * @return string|null + */ + public function nextPageUrl() + { + if (is_null($nextCursor = $this->nextCursor())) { + return null; + } + + return $this->url($nextCursor); + } + + /** + * Get the "cursor" that points to the previous set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function previousCursor() + { + if (is_null($this->cursor) || + ($this->cursor->pointsToPreviousItems() && ! $this->hasMore)) { + return null; + } + + if ($this->items->isEmpty()) { + return null; + } + + return $this->getCursorForItem($this->items->first(), false); + } + + /** + * Get the "cursor" that points to the next set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function nextCursor() + { + if ((is_null($this->cursor) && ! $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && ! $this->hasMore)) { + return null; + } + + if ($this->items->isEmpty()) { + return null; + } + + return $this->getCursorForItem($this->items->last(), true); + } + + /** + * Get a cursor instance for the given item. + * + * @param \ArrayAccess|\stdClass $item + * @param bool $isNext + * @return \Illuminate\Pagination\Cursor + */ + public function getCursorForItem($item, $isNext = true) + { + return new Cursor($this->getParametersForItem($item), $isNext); + } + + /** + * Get the cursor parameters for a given object. + * + * @param \ArrayAccess|\stdClass $item + * @return array + * + * @throws \Exception + */ + public function getParametersForItem($item) + { + return collect($this->parameters) + ->flip() + ->map(function ($_, $parameterName) use ($item) { + if ($item instanceof JsonResource) { + $item = $item->resource; + } + + if ($item instanceof Model && + ! is_null($parameter = $this->getPivotParameterForItem($item, $parameterName))) { + return $parameter; + } elseif ($item instanceof ArrayAccess || is_array($item)) { + return $this->ensureParameterIsPrimitive( + $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')] + ); + } elseif (is_object($item)) { + return $this->ensureParameterIsPrimitive( + $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')} + ); + } + + throw new Exception('Only arrays and objects are supported when cursor paginating items.'); + })->toArray(); + } + + /** + * Get the cursor parameter value from a pivot model if applicable. + * + * @param \ArrayAccess|\stdClass $item + * @param string $parameterName + * @return string|null + */ + protected function getPivotParameterForItem($item, $parameterName) + { + $table = Str::beforeLast($parameterName, '.'); + + foreach ($item->getRelations() as $relation) { + if ($relation instanceof Pivot && $relation->getTable() === $table) { + return $this->ensureParameterIsPrimitive( + $relation->getAttribute(Str::afterLast($parameterName, '.')) + ); + } + } + } + + /** + * Ensure the parameter is a primitive type. + * + * This can resolve issues that arise the developer uses a value object for an attribute. + * + * @param mixed $parameter + * @return mixed + */ + protected function ensureParameterIsPrimitive($parameter) + { + return is_object($parameter) && method_exists($parameter, '__toString') + ? (string) $parameter + : $parameter; + } + + /** + * Get / set the URL fragment to be appended to URLs. + * + * @param string|null $fragment + * @return $this|string|null + */ + public function fragment($fragment = null) + { + if (is_null($fragment)) { + return $this->fragment; + } + + $this->fragment = $fragment; + + return $this; + } + + /** + * Add a set of query string values to the paginator. + * + * @param array|string|null $key + * @param string|null $value + * @return $this + */ + public function appends($key, $value = null) + { + if (is_null($key)) { + return $this; + } + + if (is_array($key)) { + return $this->appendArray($key); + } + + return $this->addQuery($key, $value); + } + + /** + * Add an array of query string values. + * + * @param array $keys + * @return $this + */ + protected function appendArray(array $keys) + { + foreach ($keys as $key => $value) { + $this->addQuery($key, $value); + } + + return $this; + } + + /** + * Add all current query string values to the paginator. + * + * @return $this + */ + public function withQueryString() + { + if (! is_null($query = Paginator::resolveQueryString())) { + return $this->appends($query); + } + + return $this; + } + + /** + * Add a query string value to the paginator. + * + * @param string $key + * @param string $value + * @return $this + */ + protected function addQuery($key, $value) + { + if ($key !== $this->cursorName) { + $this->query[$key] = $value; + } + + return $this; + } + + /** + * Build the full fragment portion of a URL. + * + * @return string + */ + protected function buildFragment() + { + return $this->fragment ? '#'.$this->fragment : ''; + } + + /** + * Load a set of relationships onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorph($relation, $relations) + { + $this->getCollection()->loadMorph($relation, $relations); + + return $this; + } + + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + $this->getCollection()->loadMorphCount($relation, $relations); + + return $this; + } + + /** + * Get the slice of items being paginated. + * + * @return array + */ + public function items() + { + return $this->items->all(); + } + + /** + * Transform each item in the slice of items using a callback. + * + * @param callable $callback + * @return $this + */ + public function through(callable $callback) + { + $this->items->transform($callback); + + return $this; + } + + /** + * Get the number of items shown per page. + * + * @return int + */ + public function perPage() + { + return $this->perPage; + } + + /** + * Get the current cursor being paginated. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function cursor() + { + return $this->cursor; + } + + /** + * Get the query string variable used to store the cursor. + * + * @return string + */ + public function getCursorName() + { + return $this->cursorName; + } + + /** + * Set the query string variable used to store the cursor. + * + * @param string $name + * @return $this + */ + public function setCursorName($name) + { + $this->cursorName = $name; + + return $this; + } + + /** + * Set the base path to assign to all URLs. + * + * @param string $path + * @return $this + */ + public function withPath($path) + { + return $this->setPath($path); + } + + /** + * Set the base path to assign to all URLs. + * + * @param string $path + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + + return $this; + } + + /** + * Get the base path for paginator generated URLs. + * + * @return string|null + */ + public function path() + { + return $this->path; + } + + /** + * Resolve the current cursor or return the default value. + * + * @param string $cursorName + * @return \Illuminate\Pagination\Cursor|null + */ + public static function resolveCurrentCursor($cursorName = 'cursor', $default = null) + { + if (isset(static::$currentCursorResolver)) { + return call_user_func(static::$currentCursorResolver, $cursorName); + } + + return $default; + } + + /** + * Set the current cursor resolver callback. + * + * @param \Closure $resolver + * @return void + */ + public static function currentCursorResolver(Closure $resolver) + { + static::$currentCursorResolver = $resolver; + } + + /** + * Get an instance of the view factory from the resolver. + * + * @return \Illuminate\Contracts\View\Factory + */ + public static function viewFactory() + { + return Paginator::viewFactory(); + } + + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return $this->items->getIterator(); + } + + /** + * Determine if the list of items is empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->items->isEmpty(); + } + + /** + * Determine if the list of items is not empty. + * + * @return bool + */ + public function isNotEmpty() + { + return $this->items->isNotEmpty(); + } + + /** + * Get the number of items for the current page. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + return $this->items->count(); + } + + /** + * Get the paginator's underlying collection. + * + * @return \Illuminate\Support\Collection + */ + public function getCollection() + { + return $this->items; + } + + /** + * Set the paginator's underlying collection. + * + * @param \Illuminate\Support\Collection $collection + * @return $this + */ + public function setCollection(Collection $collection) + { + $this->items = $collection; + + return $this; + } + + /** + * Get the paginator options. + * + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * Determine if the given item exists. + * + * @param mixed $key + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($key) + { + return $this->items->has($key); + } + + /** + * Get the item at the given offset. + * + * @param mixed $key + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($key) + { + return $this->items->get($key); + } + + /** + * Set the item at the given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($key, $value) + { + $this->items->put($key, $value); + } + + /** + * Unset the item at the given key. + * + * @param mixed $key + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($key) + { + $this->items->forget($key); + } + + /** + * Render the contents of the paginator to HTML. + * + * @return string + */ + public function toHtml() + { + return (string) $this->render(); + } + + /** + * Make dynamic calls into the collection. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->forwardCallTo($this->getCollection(), $method, $parameters); + } + + /** + * Render the contents of the paginator when casting to a string. + * + * @return string + */ + public function __toString() + { + return (string) $this->render(); + } +} diff --git a/src/Illuminate/Pagination/AbstractPaginator.php b/src/Illuminate/Pagination/AbstractPaginator.php index e09ec3b2c89ccf5c7778aebd261e5f981f342efa..ac9ef403503f59d99e6a89ef749ce05825a9430f 100644 --- a/src/Illuminate/Pagination/AbstractPaginator.php +++ b/src/Illuminate/Pagination/AbstractPaginator.php @@ -8,13 +8,14 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; +use Illuminate\Support\Traits\Tappable; /** * @mixin \Illuminate\Support\Collection */ abstract class AbstractPaginator implements Htmlable { - use ForwardsCalls; + use ForwardsCalls, Tappable; /** * All of the items being paginated. @@ -93,6 +94,13 @@ abstract class AbstractPaginator implements Htmlable */ protected static $currentPageResolver; + /** + * The query string resolver callback. + * + * @var \Closure + */ + protected static $queryStringResolver; + /** * The view factory resolver callback. * @@ -105,14 +113,14 @@ abstract class AbstractPaginator implements Htmlable * * @var string */ - public static $defaultView = 'pagination::bootstrap-4'; + public static $defaultView = 'pagination::tailwind'; /** * The default "simple" pagination view. * * @var string */ - public static $defaultSimpleView = 'pagination::simple-bootstrap-4'; + public static $defaultSimpleView = 'pagination::simple-tailwind'; /** * Determine if the given value is a valid page number. @@ -230,6 +238,20 @@ abstract class AbstractPaginator implements Htmlable return $this; } + /** + * Add all current query string values to the paginator. + * + * @return $this + */ + public function withQueryString() + { + if (isset(static::$queryStringResolver)) { + return $this->appends(call_user_func(static::$queryStringResolver)); + } + + return $this; + } + /** * Add a query string value to the paginator. * @@ -270,6 +292,20 @@ abstract class AbstractPaginator implements Htmlable return $this; } + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + $this->getCollection()->loadMorphCount($relation, $relations); + + return $this; + } + /** * Get the slice of items being paginated. * @@ -300,6 +336,19 @@ abstract class AbstractPaginator implements Htmlable return count($this->items) > 0 ? $this->firstItem() + $this->count() - 1 : null; } + /** + * Transform each item in the slice of items using a callback. + * + * @param callable $callback + * @return $this + */ + public function through(callable $callback) + { + $this->items->transform($callback); + + return $this; + } + /** * Get the number of items shown per page. * @@ -330,6 +379,16 @@ abstract class AbstractPaginator implements Htmlable return $this->currentPage() <= 1; } + /** + * Determine if the paginator is on the last page. + * + * @return bool + */ + public function onLastPage() + { + return ! $this->hasMorePages(); + } + /** * Get the current page. * @@ -446,7 +505,7 @@ abstract class AbstractPaginator implements Htmlable public static function resolveCurrentPage($pageName = 'page', $default = 1) { if (isset(static::$currentPageResolver)) { - return call_user_func(static::$currentPageResolver, $pageName); + return (int) call_user_func(static::$currentPageResolver, $pageName); } return $default; @@ -463,6 +522,32 @@ abstract class AbstractPaginator implements Htmlable static::$currentPageResolver = $resolver; } + /** + * Resolve the query string or return the default value. + * + * @param string|array|null $default + * @return string + */ + public static function resolveQueryString($default = null) + { + if (isset(static::$queryStringResolver)) { + return (static::$queryStringResolver)(); + } + + return $default; + } + + /** + * Set with query string resolver callback. + * + * @param \Closure $resolver + * @return void + */ + public static function queryStringResolver(Closure $resolver) + { + static::$queryStringResolver = $resolver; + } + /** * Get an instance of the view factory from the resolver. * @@ -506,6 +591,28 @@ abstract class AbstractPaginator implements Htmlable static::$defaultSimpleView = $view; } + /** + * Indicate that Tailwind styling should be used for generated links. + * + * @return void + */ + public static function useTailwind() + { + static::defaultView('pagination::tailwind'); + static::defaultSimpleView('pagination::simple-tailwind'); + } + + /** + * Indicate that Bootstrap 4 styling should be used for generated links. + * + * @return void + */ + public static function useBootstrap() + { + static::defaultView('pagination::bootstrap-4'); + static::defaultSimpleView('pagination::simple-bootstrap-4'); + } + /** * Indicate that Bootstrap 3 styling should be used for generated links. * @@ -522,6 +629,7 @@ abstract class AbstractPaginator implements Htmlable * * @return \ArrayIterator */ + #[\ReturnTypeWillChange] public function getIterator() { return $this->items->getIterator(); @@ -552,6 +660,7 @@ abstract class AbstractPaginator implements Htmlable * * @return int */ + #[\ReturnTypeWillChange] public function count() { return $this->items->count(); @@ -596,6 +705,7 @@ abstract class AbstractPaginator implements Htmlable * @param mixed $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return $this->items->has($key); @@ -607,6 +717,7 @@ abstract class AbstractPaginator implements Htmlable * @param mixed $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->items->get($key); @@ -619,6 +730,7 @@ abstract class AbstractPaginator implements Htmlable * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->items->put($key, $value); @@ -630,6 +742,7 @@ abstract class AbstractPaginator implements Htmlable * @param mixed $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { $this->items->forget($key); @@ -658,7 +771,7 @@ abstract class AbstractPaginator implements Htmlable } /** - * Render the contents of the paginator when casting to string. + * Render the contents of the paginator when casting to a string. * * @return string */ diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php new file mode 100644 index 0000000000000000000000000000000000000000..e8edf6526bc8151e6cc6e6eb0c5bd72cb613eb77 --- /dev/null +++ b/src/Illuminate/Pagination/Cursor.php @@ -0,0 +1,132 @@ +<?php + +namespace Illuminate\Pagination; + +use Illuminate\Contracts\Support\Arrayable; +use UnexpectedValueException; + +class Cursor implements Arrayable +{ + /** + * The parameters associated with the cursor. + * + * @var array + */ + protected $parameters; + + /** + * Determine whether the cursor points to the next or previous set of items. + * + * @var bool + */ + protected $pointsToNextItems; + + /** + * Create a new cursor instance. + * + * @param array $parameters + * @param bool $pointsToNextItems + */ + public function __construct(array $parameters, $pointsToNextItems = true) + { + $this->parameters = $parameters; + $this->pointsToNextItems = $pointsToNextItems; + } + + /** + * Get the given parameter from the cursor. + * + * @param string $parameterName + * @return string|null + * + * @throws \UnexpectedValueException + */ + public function parameter(string $parameterName) + { + if (! array_key_exists($parameterName, $this->parameters)) { + throw new UnexpectedValueException("Unable to find parameter [{$parameterName}] in pagination item."); + } + + return $this->parameters[$parameterName]; + } + + /** + * Get the given parameters from the cursor. + * + * @param array $parameterNames + * @return array + */ + public function parameters(array $parameterNames) + { + return collect($parameterNames)->map(function ($parameterName) { + return $this->parameter($parameterName); + })->toArray(); + } + + /** + * Determine whether the cursor points to the next set of items. + * + * @return bool + */ + public function pointsToNextItems() + { + return $this->pointsToNextItems; + } + + /** + * Determine whether the cursor points to the previous set of items. + * + * @return bool + */ + public function pointsToPreviousItems() + { + return ! $this->pointsToNextItems; + } + + /** + * Get the array representation of the cursor. + * + * @return array + */ + public function toArray() + { + return array_merge($this->parameters, [ + '_pointsToNextItems' => $this->pointsToNextItems, + ]); + } + + /** + * Get the encoded string representation of the cursor to construct a URL. + * + * @return string + */ + public function encode() + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($this->toArray()))); + } + + /** + * Get a cursor instance from the encoded string representation. + * + * @param string|null $encodedString + * @return static|null + */ + public static function fromEncoded($encodedString) + { + if (is_null($encodedString) || ! is_string($encodedString)) { + return null; + } + + $parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedString)), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + $pointsToNextItems = $parameters['_pointsToNextItems']; + + unset($parameters['_pointsToNextItems']); + + return new static($parameters, $pointsToNextItems); + } +} diff --git a/src/Illuminate/Pagination/CursorPaginationException.php b/src/Illuminate/Pagination/CursorPaginationException.php new file mode 100644 index 0000000000000000000000000000000000000000..b12ca607f18572aa36356f0241c218ee514c7c6f --- /dev/null +++ b/src/Illuminate/Pagination/CursorPaginationException.php @@ -0,0 +1,13 @@ +<?php + +namespace Illuminate\Pagination; + +use RuntimeException; + +/** + * @deprecated Will be removed in a future Laravel version. + */ +class CursorPaginationException extends RuntimeException +{ + // +} diff --git a/src/Illuminate/Pagination/CursorPaginator.php b/src/Illuminate/Pagination/CursorPaginator.php new file mode 100644 index 0000000000000000000000000000000000000000..63798b94ad0d59ec80cd10fd1bc8970b835fbd0d --- /dev/null +++ b/src/Illuminate/Pagination/CursorPaginator.php @@ -0,0 +1,161 @@ +<?php + +namespace Illuminate\Pagination; + +use ArrayAccess; +use Countable; +use Illuminate\Contracts\Pagination\CursorPaginator as PaginatorContract; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Support\Collection; +use IteratorAggregate; +use JsonSerializable; + +class CursorPaginator extends AbstractCursorPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, Jsonable, JsonSerializable, PaginatorContract +{ + /** + * Indicates whether there are more items in the data source. + * + * @return bool + */ + protected $hasMore; + + /** + * Create a new paginator instance. + * + * @param mixed $items + * @param int $perPage + * @param \Illuminate\Pagination\Cursor|null $cursor + * @param array $options (path, query, fragment, pageName) + * @return void + */ + public function __construct($items, $perPage, $cursor = null, array $options = []) + { + $this->options = $options; + + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + + $this->perPage = $perPage; + $this->cursor = $cursor; + $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; + + $this->setItems($items); + } + + /** + * Set the items for the paginator. + * + * @param mixed $items + * @return void + */ + protected function setItems($items) + { + $this->items = $items instanceof Collection ? $items : Collection::make($items); + + $this->hasMore = $this->items->count() > $this->perPage; + + $this->items = $this->items->slice(0, $this->perPage); + + if (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()) { + $this->items = $this->items->reverse()->values(); + } + } + + /** + * Render the paginator using the given view. + * + * @param string|null $view + * @param array $data + * @return \Illuminate\Contracts\Support\Htmlable + */ + public function links($view = null, $data = []) + { + return $this->render($view, $data); + } + + /** + * Render the paginator using the given view. + * + * @param string|null $view + * @param array $data + * @return \Illuminate\Contracts\Support\Htmlable + */ + public function render($view = null, $data = []) + { + return static::viewFactory()->make($view ?: Paginator::$defaultSimpleView, array_merge($data, [ + 'paginator' => $this, + ])); + } + + /** + * Determine if there are more items in the data source. + * + * @return bool + */ + public function hasMorePages() + { + return (is_null($this->cursor) && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()); + } + + /** + * Determine if there are enough items to split into multiple pages. + * + * @return bool + */ + public function hasPages() + { + return ! $this->onFirstPage() || $this->hasMorePages(); + } + + /** + * Determine if the paginator is on the first page. + * + * @return bool + */ + public function onFirstPage() + { + return is_null($this->cursor) || ($this->cursor->pointsToPreviousItems() && ! $this->hasMore); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return [ + 'data' => $this->items->toArray(), + 'path' => $this->path(), + 'per_page' => $this->perPage(), + 'next_page_url' => $this->nextPageUrl(), + 'prev_page_url' => $this->previousPageUrl(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Convert the object to its JSON representation. + * + * @param int $options + * @return string + */ + public function toJson($options = 0) + { + return json_encode($this->jsonSerialize(), $options); + } +} diff --git a/src/Illuminate/Pagination/LengthAwarePaginator.php b/src/Illuminate/Pagination/LengthAwarePaginator.php index edf0c20ec7e3bdd0b740ea4a1098eaa913308b89..24f68b121b8077bc1be93d7ab4aae6b5b7144fe5 100644 --- a/src/Illuminate/Pagination/LengthAwarePaginator.php +++ b/src/Illuminate/Pagination/LengthAwarePaginator.php @@ -34,7 +34,7 @@ class LengthAwarePaginator extends AbstractPaginator implements Arrayable, Array * @param int $total * @param int $perPage * @param int|null $currentPage - * @param array $options (path, query, fragment, pageName) + * @param array $options (path, query, fragment, pageName) * @return void */ public function __construct($items, $total, $perPage, $currentPage = null, array $options = []) @@ -94,6 +94,36 @@ class LengthAwarePaginator extends AbstractPaginator implements Arrayable, Array ])); } + /** + * Get the paginator links as a collection (for JSON responses). + * + * @return \Illuminate\Support\Collection + */ + public function linkCollection() + { + return collect($this->elements())->flatMap(function ($item) { + if (! is_array($item)) { + return [['url' => null, 'label' => '...', 'active' => false]]; + } + + return collect($item)->map(function ($url, $page) { + return [ + 'url' => $url, + 'label' => (string) $page, + 'active' => $this->currentPage() === $page, + ]; + }); + })->prepend([ + 'url' => $this->previousPageUrl(), + 'label' => function_exists('__') ? __('pagination.previous') : 'Previous', + 'active' => false, + ])->push([ + 'url' => $this->nextPageUrl(), + 'label' => function_exists('__') ? __('pagination.next') : 'Next', + 'active' => false, + ]); + } + /** * Get the array of elements to pass to the view. * @@ -139,7 +169,7 @@ class LengthAwarePaginator extends AbstractPaginator implements Arrayable, Array */ public function nextPageUrl() { - if ($this->lastPage() > $this->currentPage()) { + if ($this->hasMorePages()) { return $this->url($this->currentPage() + 1); } } @@ -168,6 +198,7 @@ class LengthAwarePaginator extends AbstractPaginator implements Arrayable, Array 'from' => $this->firstItem(), 'last_page' => $this->lastPage(), 'last_page_url' => $this->url($this->lastPage()), + 'links' => $this->linkCollection()->toArray(), 'next_page_url' => $this->nextPageUrl(), 'path' => $this->path(), 'per_page' => $this->perPage(), @@ -182,6 +213,7 @@ class LengthAwarePaginator extends AbstractPaginator implements Arrayable, Array * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); diff --git a/src/Illuminate/Pagination/PaginationServiceProvider.php b/src/Illuminate/Pagination/PaginationServiceProvider.php index ed58ccf6b98567b0b5ca7afee3b7b94389115050..e94cebd6caf7eb7448008155da049865be3d4d14 100755 --- a/src/Illuminate/Pagination/PaginationServiceProvider.php +++ b/src/Illuminate/Pagination/PaginationServiceProvider.php @@ -29,22 +29,6 @@ class PaginationServiceProvider extends ServiceProvider */ public function register() { - Paginator::viewFactoryResolver(function () { - return $this->app['view']; - }); - - Paginator::currentPathResolver(function () { - return $this->app['request']->url(); - }); - - Paginator::currentPageResolver(function ($pageName = 'page') { - $page = $this->app['request']->input($pageName); - - if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) { - return (int) $page; - } - - return 1; - }); + PaginationState::resolveUsing($this->app); } } diff --git a/src/Illuminate/Pagination/PaginationState.php b/src/Illuminate/Pagination/PaginationState.php new file mode 100644 index 0000000000000000000000000000000000000000..ff8150ff2a9ee0b08b62fe03467768a75d222ad4 --- /dev/null +++ b/src/Illuminate/Pagination/PaginationState.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\Pagination; + +class PaginationState +{ + /** + * Bind the pagination state resolvers using the given application container as a base. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return void + */ + public static function resolveUsing($app) + { + Paginator::viewFactoryResolver(function () use ($app) { + return $app['view']; + }); + + Paginator::currentPathResolver(function () use ($app) { + return $app['request']->url(); + }); + + Paginator::currentPageResolver(function ($pageName = 'page') use ($app) { + $page = $app['request']->input($pageName); + + if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) { + return (int) $page; + } + + return 1; + }); + + Paginator::queryStringResolver(function () use ($app) { + return $app['request']->query(); + }); + + CursorPaginator::currentCursorResolver(function ($cursorName = 'cursor') use ($app) { + return Cursor::fromEncoded($app['request']->input($cursorName)); + }); + } +} diff --git a/src/Illuminate/Pagination/Paginator.php b/src/Illuminate/Pagination/Paginator.php index dfe1464656565343ddc8eb4f026c420c55b7b1ee..733edb8e00fed2afb10d4d253027b3fb3b405b68 100644 --- a/src/Illuminate/Pagination/Paginator.php +++ b/src/Illuminate/Pagination/Paginator.php @@ -26,7 +26,7 @@ class Paginator extends AbstractPaginator implements Arrayable, ArrayAccess, Cou * @param mixed $items * @param int $perPage * @param int|null $currentPage - * @param array $options (path, query, fragment, pageName) + * @param array $options (path, query, fragment, pageName) * @return void */ public function __construct($items, $perPage, $currentPage = null, array $options = []) @@ -158,6 +158,7 @@ class Paginator extends AbstractPaginator implements Arrayable, ArrayAccess, Cou * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); diff --git a/src/Illuminate/Pagination/UrlWindow.php b/src/Illuminate/Pagination/UrlWindow.php index 0fd3aa8230b4515388ac2a6a96adb96e579b46b7..31c7cc2a4302222489ad216052eea668da00ab26 100644 --- a/src/Illuminate/Pagination/UrlWindow.php +++ b/src/Illuminate/Pagination/UrlWindow.php @@ -44,7 +44,7 @@ class UrlWindow { $onEachSide = $this->paginator->onEachSide; - if ($this->paginator->lastPage() < ($onEachSide * 2) + 6) { + if ($this->paginator->lastPage() < ($onEachSide * 2) + 8) { return $this->getSmallSlider(); } @@ -59,9 +59,9 @@ class UrlWindow protected function getSmallSlider() { return [ - 'first' => $this->paginator->getUrlRange(1, $this->lastPage()), + 'first' => $this->paginator->getUrlRange(1, $this->lastPage()), 'slider' => null, - 'last' => null, + 'last' => null, ]; } @@ -73,7 +73,7 @@ class UrlWindow */ protected function getUrlSlider($onEachSide) { - $window = $onEachSide * 2; + $window = $onEachSide + 4; if (! $this->hasPages()) { return ['first' => null, 'slider' => null, 'last' => null]; @@ -83,14 +83,14 @@ class UrlWindow // just render the beginning of the page range, followed by the last 2 of the // links in this list, since we will not have room to create a full slider. if ($this->currentPage() <= $window) { - return $this->getSliderTooCloseToBeginning($window); + return $this->getSliderTooCloseToBeginning($window, $onEachSide); } // If the current page is close to the ending of the page range we will just get // this first couple pages, followed by a larger window of these ending pages // since we're too close to the end of the list to create a full on slider. elseif ($this->currentPage() > ($this->lastPage() - $window)) { - return $this->getSliderTooCloseToEnding($window); + return $this->getSliderTooCloseToEnding($window, $onEachSide); } // If we have enough room on both sides of the current page to build a slider we @@ -103,12 +103,13 @@ class UrlWindow * Get the slider of URLs when too close to beginning of window. * * @param int $window + * @param int $onEachSide * @return array */ - protected function getSliderTooCloseToBeginning($window) + protected function getSliderTooCloseToBeginning($window, $onEachSide) { return [ - 'first' => $this->paginator->getUrlRange(1, $window + 2), + 'first' => $this->paginator->getUrlRange(1, $window + $onEachSide), 'slider' => null, 'last' => $this->getFinish(), ]; @@ -118,12 +119,13 @@ class UrlWindow * Get the slider of URLs when too close to ending of window. * * @param int $window + * @param int $onEachSide * @return array */ - protected function getSliderTooCloseToEnding($window) + protected function getSliderTooCloseToEnding($window, $onEachSide) { $last = $this->paginator->getUrlRange( - $this->lastPage() - ($window + 2), + $this->lastPage() - ($window + ($onEachSide - 1)), $this->lastPage() ); @@ -143,9 +145,9 @@ class UrlWindow protected function getFullSlider($onEachSide) { return [ - 'first' => $this->getStart(), + 'first' => $this->getStart(), 'slider' => $this->getAdjacentUrlRange($onEachSide), - 'last' => $this->getFinish(), + 'last' => $this->getFinish(), ]; } diff --git a/src/Illuminate/Pagination/composer.json b/src/Illuminate/Pagination/composer.json index c8f966516656c2048ef075dc527c11548f94d5b9..5c8a380b2a37cc8d7e426fdfc11e2249c92cdba9 100755 --- a/src/Illuminate/Pagination/composer.json +++ b/src/Illuminate/Pagination/composer.json @@ -14,10 +14,11 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -26,7 +27,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Pagination/resources/views/simple-tailwind.blade.php b/src/Illuminate/Pagination/resources/views/simple-tailwind.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..6872cca360d5e11ed6df0ea78836130425b2b6f7 --- /dev/null +++ b/src/Illuminate/Pagination/resources/views/simple-tailwind.blade.php @@ -0,0 +1,25 @@ +@if ($paginator->hasPages()) + <nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between"> + {{-- Previous Page Link --}} + @if ($paginator->onFirstPage()) + <span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md"> + {!! __('pagination.previous') !!} + </span> + @else + <a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> + {!! __('pagination.previous') !!} + </a> + @endif + + {{-- Next Page Link --}} + @if ($paginator->hasMorePages()) + <a href="{{ $paginator->nextPageUrl() }}" rel="next" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> + {!! __('pagination.next') !!} + </a> + @else + <span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md"> + {!! __('pagination.next') !!} + </span> + @endif + </nav> +@endif diff --git a/src/Illuminate/Pagination/resources/views/tailwind.blade.php b/src/Illuminate/Pagination/resources/views/tailwind.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..5bf323b406f2c5e3afbc895ffc4fa81d0e32613e --- /dev/null +++ b/src/Illuminate/Pagination/resources/views/tailwind.blade.php @@ -0,0 +1,106 @@ +@if ($paginator->hasPages()) + <nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="flex items-center justify-between"> + <div class="flex justify-between flex-1 sm:hidden"> + @if ($paginator->onFirstPage()) + <span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md"> + {!! __('pagination.previous') !!} + </span> + @else + <a href="{{ $paginator->previousPageUrl() }}" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> + {!! __('pagination.previous') !!} + </a> + @endif + + @if ($paginator->hasMorePages()) + <a href="{{ $paginator->nextPageUrl() }}" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> + {!! __('pagination.next') !!} + </a> + @else + <span class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md"> + {!! __('pagination.next') !!} + </span> + @endif + </div> + + <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> + <div> + <p class="text-sm text-gray-700 leading-5"> + {!! __('Showing') !!} + @if ($paginator->firstItem()) + <span class="font-medium">{{ $paginator->firstItem() }}</span> + {!! __('to') !!} + <span class="font-medium">{{ $paginator->lastItem() }}</span> + @else + {{ $paginator->count() }} + @endif + {!! __('of') !!} + <span class="font-medium">{{ $paginator->total() }}</span> + {!! __('results') !!} + </p> + </div> + + <div> + <span class="relative z-0 inline-flex shadow-sm rounded-md"> + {{-- Previous Page Link --}} + @if ($paginator->onFirstPage()) + <span aria-disabled="true" aria-label="{{ __('pagination.previous') }}"> + <span class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-l-md leading-5" aria-hidden="true"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> + </svg> + </span> + </span> + @else + <a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.previous') }}"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> + </svg> + </a> + @endif + + {{-- Pagination Elements --}} + @foreach ($elements as $element) + {{-- "Three Dots" Separator --}} + @if (is_string($element)) + <span aria-disabled="true"> + <span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 cursor-default leading-5">{{ $element }}</span> + </span> + @endif + + {{-- Array Of Links --}} + @if (is_array($element)) + @foreach ($element as $page => $url) + @if ($page == $paginator->currentPage()) + <span aria-current="page"> + <span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5">{{ $page }}</span> + </span> + @else + <a href="{{ $url }}" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" aria-label="{{ __('Go to page :page', ['page' => $page]) }}"> + {{ $page }} + </a> + @endif + @endforeach + @endif + @endforeach + + {{-- Next Page Link --}} + @if ($paginator->hasMorePages()) + <a href="{{ $paginator->nextPageUrl() }}" rel="next" class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150" aria-label="{{ __('pagination.next') }}"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> + </svg> + </a> + @else + <span aria-disabled="true" aria-label="{{ __('pagination.next') }}"> + <span class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-r-md leading-5" aria-hidden="true"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> + </svg> + </span> + </span> + @endif + </span> + </div> + </div> + </nav> +@endif diff --git a/src/Illuminate/Pipeline/Hub.php b/src/Illuminate/Pipeline/Hub.php index 87331a57b2c67cd1e84207d1345bfb54f9b0156e..91e9b3f306b8761e8ca9f2790f649d475f27ff94 100644 --- a/src/Illuminate/Pipeline/Hub.php +++ b/src/Illuminate/Pipeline/Hub.php @@ -71,4 +71,27 @@ class Hub implements HubContract $this->pipelines[$pipeline], new Pipeline($this->container), $object ); } + + /** + * Get the container instance used by the hub. + * + * @return \Illuminate\Contracts\Container\Container + */ + public function getContainer() + { + return $this->container; + } + + /** + * Set the container instance used by the hub. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } } diff --git a/src/Illuminate/Pipeline/Pipeline.php b/src/Illuminate/Pipeline/Pipeline.php index 32b80e2a0fa556924b068fc681b323074a99c635..d2924e5364686c69a9bb7896acb3cb67979707da 100644 --- a/src/Illuminate/Pipeline/Pipeline.php +++ b/src/Illuminate/Pipeline/Pipeline.php @@ -3,11 +3,9 @@ namespace Illuminate\Pipeline; use Closure; -use Exception; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Pipeline\Pipeline as PipelineContract; use RuntimeException; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Throwable; class Pipeline implements PipelineContract @@ -128,10 +126,8 @@ class Pipeline implements PipelineContract return function ($passable) use ($destination) { try { return $destination($passable); - } catch (Exception $e) { - return $this->handleException($passable, $e); } catch (Throwable $e) { - return $this->handleException($passable, new FatalThrowableError($e)); + return $this->handleException($passable, $e); } }; } @@ -172,10 +168,8 @@ class Pipeline implements PipelineContract : $pipe(...$parameters); return $this->handleCarry($carry); - } catch (Exception $e) { - return $this->handleException($passable, $e); } catch (Throwable $e) { - return $this->handleException($passable, new FatalThrowableError($e)); + return $this->handleException($passable, $e); } }; }; @@ -224,6 +218,19 @@ class Pipeline implements PipelineContract return $this->container; } + /** + * Set the container instance. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } + /** * Handle the value returned from each pipe before passing it to the next. * @@ -239,12 +246,12 @@ class Pipeline implements PipelineContract * Handle the given exception. * * @param mixed $passable - * @param \Exception $e + * @param \Throwable $e * @return mixed * - * @throws \Exception + * @throws \Throwable */ - protected function handleException($passable, Exception $e) + protected function handleException($passable, Throwable $e) { throw $e; } diff --git a/src/Illuminate/Pipeline/composer.json b/src/Illuminate/Pipeline/composer.json index 1d6a6b931d5027fc5d5a56f16d21f4de2221d2a9..ef6805bdef63ae912f69a062333fb4806851929a 100644 --- a/src/Illuminate/Pipeline/composer.json +++ b/src/Illuminate/Pipeline/composer.json @@ -14,10 +14,9 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "symfony/debug": "^4.3.4" + "php": "^7.3|^8.0", + "illuminate/contracts": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -26,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Queue/BeanstalkdQueue.php b/src/Illuminate/Queue/BeanstalkdQueue.php index 49c36bdac07ffee5b107278ee0ff870f631629e7..b258c49418ce4984e6f2a168963e4c8580639ecc 100755 --- a/src/Illuminate/Queue/BeanstalkdQueue.php +++ b/src/Illuminate/Queue/BeanstalkdQueue.php @@ -44,14 +44,20 @@ class BeanstalkdQueue extends Queue implements QueueContract * @param string $default * @param int $timeToRun * @param int $blockFor + * @param bool $dispatchAfterCommit * @return void */ - public function __construct(Pheanstalk $pheanstalk, $default, $timeToRun, $blockFor = 0) + public function __construct(Pheanstalk $pheanstalk, + $default, + $timeToRun, + $blockFor = 0, + $dispatchAfterCommit = false) { $this->default = $default; $this->blockFor = $blockFor; $this->timeToRun = $timeToRun; $this->pheanstalk = $pheanstalk; + $this->dispatchAfterCommit = $dispatchAfterCommit; } /** @@ -77,7 +83,15 @@ class BeanstalkdQueue extends Queue implements QueueContract */ public function push($job, $data = '', $queue = null) { - return $this->pushRaw($this->createPayload($job, $this->getQueue($queue), $data), $queue); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + null, + function ($payload, $queue) { + return $this->pushRaw($payload, $queue); + } + ); } /** @@ -106,13 +120,19 @@ class BeanstalkdQueue extends Queue implements QueueContract */ public function later($delay, $job, $data = '', $queue = null) { - $pheanstalk = $this->pheanstalk->useTube($this->getQueue($queue)); - - return $pheanstalk->put( + return $this->enqueueUsing( + $job, $this->createPayload($job, $this->getQueue($queue), $data), - Pheanstalk::DEFAULT_PRIORITY, - $this->secondsUntil($delay), - $this->timeToRun + $queue, + $delay, + function ($payload, $queue, $delay) { + return $this->pheanstalk->useTube($this->getQueue($queue))->put( + $payload, + Pheanstalk::DEFAULT_PRIORITY, + $this->secondsUntil($delay), + $this->timeToRun + ); + } ); } diff --git a/src/Illuminate/Queue/CallQueuedClosure.php b/src/Illuminate/Queue/CallQueuedClosure.php index e653b2555df2d7fe97b75d3fd06a14da50826dcf..24a72d966c57a490a7868be70ff52a59f91d3782 100644 --- a/src/Illuminate/Queue/CallQueuedClosure.php +++ b/src/Illuminate/Queue/CallQueuedClosure.php @@ -3,6 +3,7 @@ namespace Illuminate\Queue; use Closure; +use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Queue\ShouldQueue; @@ -11,15 +12,22 @@ use ReflectionFunction; class CallQueuedClosure implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * The serializable Closure instance. * - * @var \Illuminate\Queue\SerializableClosure + * @var \Laravel\SerializableClosure\SerializableClosure */ public $closure; + /** + * The callbacks that should be executed on failure. + * + * @var array + */ + public $failureCallbacks = []; + /** * Indicate if the job should be deleted when models are missing. * @@ -30,10 +38,10 @@ class CallQueuedClosure implements ShouldQueue /** * Create a new job instance. * - * @param \Illuminate\Queue\SerializableClosure $closure + * @param \Laravel\SerializableClosure\SerializableClosure $closure * @return void */ - public function __construct(SerializableClosure $closure) + public function __construct($closure) { $this->closure = $closure; } @@ -46,7 +54,7 @@ class CallQueuedClosure implements ShouldQueue */ public static function create(Closure $job) { - return new self(new SerializableClosure($job)); + return new self(SerializableClosureFactory::make($job)); } /** @@ -57,7 +65,35 @@ class CallQueuedClosure implements ShouldQueue */ public function handle(Container $container) { - $container->call($this->closure->getClosure()); + $container->call($this->closure->getClosure(), ['job' => $this]); + } + + /** + * Add a callback to be executed if the job fails. + * + * @param callable $callback + * @return $this + */ + public function onFailure($callback) + { + $this->failureCallbacks[] = $callback instanceof Closure + ? SerializableClosureFactory::make($callback) + : $callback; + + return $this; + } + + /** + * Handle a job failure. + * + * @param \Throwable $e + * @return void + */ + public function failed($e) + { + foreach ($this->failureCallbacks as $callback) { + $callback($e); + } } /** diff --git a/src/Illuminate/Queue/CallQueuedHandler.php b/src/Illuminate/Queue/CallQueuedHandler.php index a3241abbe1c2457b5687078c1fb894128c4df7d4..dcd8210e3d82514b39b4ba3f69fdc58530bb372b 100644 --- a/src/Illuminate/Queue/CallQueuedHandler.php +++ b/src/Illuminate/Queue/CallQueuedHandler.php @@ -3,12 +3,19 @@ namespace Illuminate\Queue; use Exception; +use Illuminate\Bus\Batchable; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Queue\Job; +use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pipeline\Pipeline; +use Illuminate\Support\Str; use ReflectionClass; +use RuntimeException; class CallQueuedHandler { @@ -50,16 +57,25 @@ class CallQueuedHandler { try { $command = $this->setJobInstanceIfNecessary( - $job, unserialize($data['command']) + $job, $this->getCommand($data) ); } catch (ModelNotFoundException $e) { return $this->handleModelNotFound($job, $e); } + if ($command instanceof ShouldBeUniqueUntilProcessing) { + $this->ensureUniqueJobLockIsReleased($command); + } + $this->dispatchThroughMiddleware($job, $command); + if (! $job->isReleased() && ! $command instanceof ShouldBeUniqueUntilProcessing) { + $this->ensureUniqueJobLockIsReleased($command); + } + if (! $job->hasFailed() && ! $job->isReleased()) { $this->ensureNextJobInChainIsDispatched($command); + $this->ensureSuccessfulBatchJobIsRecorded($command); } if (! $job->isDeletedOrReleased()) { @@ -67,6 +83,27 @@ class CallQueuedHandler } } + /** + * Get the command from the given payload. + * + * @param array $data + * @return mixed + * + * @throws \RuntimeException + */ + protected function getCommand(array $data) + { + if (Str::startsWith($data['command'], 'O:')) { + return unserialize($data['command']); + } + + if ($this->container->bound(Encrypter::class)) { + return unserialize($this->container[Encrypter::class]->decrypt($data['command'])); + } + + throw new RuntimeException('Unable to extract job payload.'); + } + /** * Dispatch the given job / command through its specified middleware. * @@ -132,11 +169,55 @@ class CallQueuedHandler } } + /** + * Ensure the batch is notified of the successful job completion. + * + * @param mixed $command + * @return void + */ + protected function ensureSuccessfulBatchJobIsRecorded($command) + { + $uses = class_uses_recursive($command); + + if (! in_array(Batchable::class, $uses) || + ! in_array(InteractsWithQueue::class, $uses) || + is_null($command->batch())) { + return; + } + + $command->batch()->recordSuccessfulJob($command->job->uuid()); + } + + /** + * Ensure the lock for a unique job is released. + * + * @param mixed $command + * @return void + */ + protected function ensureUniqueJobLockIsReleased($command) + { + if (! $command instanceof ShouldBeUnique) { + return; + } + + $uniqueId = method_exists($command, 'uniqueId') + ? $command->uniqueId() + : ($command->uniqueId ?? ''); + + $cache = method_exists($command, 'uniqueVia') + ? $command->uniqueVia() + : $this->container->make(Cache::class); + + $cache->lock( + 'laravel_unique_job:'.get_class($command).$uniqueId + )->forceRelease(); + } + /** * Handle a model not found exception. * * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $e + * @param \Throwable $e * @return void */ protected function handleModelNotFound(Job $job, $e) @@ -163,15 +244,56 @@ class CallQueuedHandler * The exception that caused the failure will be passed. * * @param array $data - * @param \Exception $e + * @param \Throwable|null $e + * @param string $uuid * @return void */ - public function failed(array $data, $e) + public function failed(array $data, $e, string $uuid) { - $command = unserialize($data['command']); + $command = $this->getCommand($data); + + if (! $command instanceof ShouldBeUniqueUntilProcessing) { + $this->ensureUniqueJobLockIsReleased($command); + } + + $this->ensureFailedBatchJobIsRecorded($uuid, $command, $e); + $this->ensureChainCatchCallbacksAreInvoked($uuid, $command, $e); if (method_exists($command, 'failed')) { $command->failed($e); } } + + /** + * Ensure the batch is notified of the failed job. + * + * @param string $uuid + * @param mixed $command + * @param \Throwable $e + * @return void + */ + protected function ensureFailedBatchJobIsRecorded(string $uuid, $command, $e) + { + if (! in_array(Batchable::class, class_uses_recursive($command)) || + is_null($command->batch())) { + return; + } + + $command->batch()->recordFailedJob($uuid, $e); + } + + /** + * Ensure the chained job catch callbacks are invoked. + * + * @param string $uuid + * @param mixed $command + * @param \Throwable $e + * @return void + */ + protected function ensureChainCatchCallbacksAreInvoked(string $uuid, $command, $e) + { + if (method_exists($command, 'invokeChainCatchCallbacks')) { + $command->invokeChainCatchCallbacks($e); + } + } } diff --git a/src/Illuminate/Queue/Connectors/BeanstalkdConnector.php b/src/Illuminate/Queue/Connectors/BeanstalkdConnector.php index b54d80193b694a3998bbd673ead56b87d68ce7e9..fdcdb355594e9861dcdd23b77c63b613e133aefd 100755 --- a/src/Illuminate/Queue/Connectors/BeanstalkdConnector.php +++ b/src/Illuminate/Queue/Connectors/BeanstalkdConnector.php @@ -20,7 +20,8 @@ class BeanstalkdConnector implements ConnectorInterface $this->pheanstalk($config), $config['queue'], $config['retry_after'] ?? Pheanstalk::DEFAULT_TTR, - $config['block_for'] ?? 0 + $config['block_for'] ?? 0, + $config['after_commit'] ?? null ); } diff --git a/src/Illuminate/Queue/Connectors/DatabaseConnector.php b/src/Illuminate/Queue/Connectors/DatabaseConnector.php index 893a898f6b669843d88d41163613fbda8272a808..eeabc8ee7f7ffe2a2f94af063ceed384c3419761 100644 --- a/src/Illuminate/Queue/Connectors/DatabaseConnector.php +++ b/src/Illuminate/Queue/Connectors/DatabaseConnector.php @@ -37,7 +37,8 @@ class DatabaseConnector implements ConnectorInterface $this->connections->connection($config['connection'] ?? null), $config['table'], $config['queue'], - $config['retry_after'] ?? 60 + $config['retry_after'] ?? 60, + $config['after_commit'] ?? null ); } } diff --git a/src/Illuminate/Queue/Connectors/RedisConnector.php b/src/Illuminate/Queue/Connectors/RedisConnector.php index 1efe5f65e903bc6e5b40bfbe610950efe3bc0308..966fe49071a8c1ad2dabb9aa6c3931324c1aa668 100644 --- a/src/Illuminate/Queue/Connectors/RedisConnector.php +++ b/src/Illuminate/Queue/Connectors/RedisConnector.php @@ -46,7 +46,8 @@ class RedisConnector implements ConnectorInterface $this->redis, $config['queue'], $config['connection'] ?? $this->connection, $config['retry_after'] ?? 60, - $config['block_for'] ?? null + $config['block_for'] ?? null, + $config['after_commit'] ?? null ); } } diff --git a/src/Illuminate/Queue/Connectors/SqsConnector.php b/src/Illuminate/Queue/Connectors/SqsConnector.php index 9987f922ec68c8a3864dd41a70787ae1c0982ece..029c607c432807b02a0ee1246af3853bf93c0ae5 100755 --- a/src/Illuminate/Queue/Connectors/SqsConnector.php +++ b/src/Illuminate/Queue/Connectors/SqsConnector.php @@ -23,7 +23,11 @@ class SqsConnector implements ConnectorInterface } return new SqsQueue( - new SqsClient($config), $config['queue'], $config['prefix'] ?? '' + new SqsClient($config), + $config['queue'], + $config['prefix'] ?? '', + $config['suffix'] ?? '', + $config['after_commit'] ?? null ); } diff --git a/src/Illuminate/Queue/Console/BatchesTableCommand.php b/src/Illuminate/Queue/Console/BatchesTableCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..8d482796e4024bd64e098820d13f4af3daaf7ae9 --- /dev/null +++ b/src/Illuminate/Queue/Console/BatchesTableCommand.php @@ -0,0 +1,102 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Illuminate\Console\Command; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Composer; +use Illuminate\Support\Str; + +class BatchesTableCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $name = 'queue:batches-table'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Create a migration for the batches database table'; + + /** + * The filesystem instance. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $files; + + /** + * @var \Illuminate\Support\Composer + */ + protected $composer; + + /** + * Create a new batched queue jobs table command instance. + * + * @param \Illuminate\Filesystem\Filesystem $files + * @param \Illuminate\Support\Composer $composer + * @return void + */ + public function __construct(Filesystem $files, Composer $composer) + { + parent::__construct(); + + $this->files = $files; + $this->composer = $composer; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $table = $this->laravel['config']['queue.batching.table'] ?? 'job_batches'; + + $this->replaceMigration( + $this->createBaseMigration($table), $table, Str::studly($table) + ); + + $this->info('Migration created successfully!'); + + $this->composer->dumpAutoloads(); + } + + /** + * Create a base migration file for the table. + * + * @param string $table + * @return string + */ + protected function createBaseMigration($table = 'job_batches') + { + return $this->laravel['migration.creator']->create( + 'create_'.$table.'_table', $this->laravel->databasePath().'/migrations' + ); + } + + /** + * Replace the generated migration with the batches job table stub. + * + * @param string $path + * @param string $table + * @param string $tableClassName + * @return void + */ + protected function replaceMigration($path, $table, $tableClassName) + { + $stub = str_replace( + ['{{table}}', '{{tableClassName}}'], + [$table, $tableClassName], + $this->files->get(__DIR__.'/stubs/batches.stub') + ); + + $this->files->put($path, $stub); + } +} diff --git a/src/Illuminate/Queue/Console/ClearCommand.php b/src/Illuminate/Queue/Console/ClearCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..ff9f936021f8eb4348ca835526dfa53ee52d3c37 --- /dev/null +++ b/src/Illuminate/Queue/Console/ClearCommand.php @@ -0,0 +1,100 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; +use Illuminate\Contracts\Queue\ClearableQueue; +use ReflectionClass; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; + +class ClearCommand extends Command +{ + use ConfirmableTrait; + + /** + * The console command name. + * + * @var string + */ + protected $name = 'queue:clear'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Delete all of the jobs from the specified queue'; + + /** + * Execute the console command. + * + * @return int|null + */ + public function handle() + { + if (! $this->confirmToProceed()) { + return 1; + } + + $connection = $this->argument('connection') + ?: $this->laravel['config']['queue.default']; + + // We need to get the right queue for the connection which is set in the queue + // configuration file for the application. We will pull it based on the set + // connection being run for the queue operation currently being executed. + $queueName = $this->getQueue($connection); + + $queue = ($this->laravel['queue'])->connection($connection); + + if ($queue instanceof ClearableQueue) { + $count = $queue->clear($queueName); + + $this->line('<info>Cleared '.$count.' jobs from the ['.$queueName.'] queue</info> '); + } else { + $this->line('<error>Clearing queues is not supported on ['.(new ReflectionClass($queue))->getShortName().']</error> '); + } + + return 0; + } + + /** + * Get the queue name to clear. + * + * @param string $connection + * @return string + */ + protected function getQueue($connection) + { + return $this->option('queue') ?: $this->laravel['config']->get( + "queue.connections.{$connection}.queue", 'default' + ); + } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getArguments() + { + return [ + ['connection', InputArgument::OPTIONAL, 'The name of the queue connection to clear'], + ]; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['queue', null, InputOption::VALUE_OPTIONAL, 'The name of the queue to clear'], + + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], + ]; + } +} diff --git a/src/Illuminate/Queue/Console/ListFailedCommand.php b/src/Illuminate/Queue/Console/ListFailedCommand.php index 614b8977432f2e4874927e5f397cb80e9ab591f4..66b5ddd52d8635b57ff0edd78737ed413b499b9d 100644 --- a/src/Illuminate/Queue/Console/ListFailedCommand.php +++ b/src/Illuminate/Queue/Console/ListFailedCommand.php @@ -24,7 +24,7 @@ class ListFailedCommand extends Command /** * The table headers for the command. * - * @var array + * @var string[] */ protected $headers = ['ID', 'Connection', 'Queue', 'Class', 'Failed At']; @@ -66,7 +66,7 @@ class ListFailedCommand extends Command { $row = array_values(Arr::except($failed, ['payload', 'exception'])); - array_splice($row, 3, 0, $this->extractJobName($failed['payload'])); + array_splice($row, 3, 0, $this->extractJobName($failed['payload']) ?: ''); return $row; } @@ -92,7 +92,7 @@ class ListFailedCommand extends Command * Match the job name from the payload. * * @param array $payload - * @return string + * @return string|null */ protected function matchJobName($payload) { diff --git a/src/Illuminate/Queue/Console/ListenCommand.php b/src/Illuminate/Queue/Console/ListenCommand.php index ff4ed1af24d955b0955bdba5cd8fea85017449ad..f650e43195e357c5be1224c0cb1318da1b82a340 100755 --- a/src/Illuminate/Queue/Console/ListenCommand.php +++ b/src/Illuminate/Queue/Console/ListenCommand.php @@ -15,7 +15,9 @@ class ListenCommand extends Command */ protected $signature = 'queue:listen {connection? : The name of connection} - {--delay=0 : The number of seconds to delay failed jobs} + {--name=default : The name of the worker} + {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} + {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} {--force : Force the worker to run even in maintenance mode} {--memory=128 : The memory limit in megabytes} {--queue= : The queue to listen on} @@ -91,10 +93,18 @@ class ListenCommand extends Command */ protected function gatherOptions() { + $backoff = $this->hasOption('backoff') + ? $this->option('backoff') + : $this->option('delay'); + return new ListenerOptions( - $this->option('env'), $this->option('delay'), - $this->option('memory'), $this->option('timeout'), - $this->option('sleep'), $this->option('tries'), + $this->option('name'), + $this->option('env'), + $backoff, + $this->option('memory'), + $this->option('timeout'), + $this->option('sleep'), + $this->option('tries'), $this->option('force') ); } diff --git a/src/Illuminate/Queue/Console/MonitorCommand.php b/src/Illuminate/Queue/Console/MonitorCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..1deb479ae6983ab7f5729a2284a163cadcd89330 --- /dev/null +++ b/src/Illuminate/Queue/Console/MonitorCommand.php @@ -0,0 +1,137 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Illuminate\Console\Command; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Contracts\Queue\Factory; +use Illuminate\Queue\Events\QueueBusy; +use Illuminate\Support\Collection; + +class MonitorCommand extends Command +{ + /** + * The console command name. + * + * @var string + */ + protected $signature = 'queue:monitor + {queues : The names of the queues to monitor} + {--max=1000 : The maximum number of jobs that can be on the queue before an event is dispatched}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Monitor the size of the specified queues'; + + /** + * The queue manager instance. + * + * @var \Illuminate\Contracts\Queue\Factory + */ + protected $manager; + + /** + * The events dispatcher instance. + * + * @var \Illuminate\Contracts\Events\Dispatcher + */ + protected $events; + + /** + * The table headers for the command. + * + * @var string[] + */ + protected $headers = ['Connection', 'Queue', 'Size', 'Status']; + + /** + * Create a new queue listen command. + * + * @param \Illuminate\Contracts\Queue\Factory $manager + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return void + */ + public function __construct(Factory $manager, Dispatcher $events) + { + parent::__construct(); + + $this->manager = $manager; + $this->events = $events; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $queues = $this->parseQueues($this->argument('queues')); + + $this->displaySizes($queues); + + $this->dispatchEvents($queues); + } + + /** + * Parse the queues into an array of the connections and queues. + * + * @param string $queues + * @return \Illuminate\Support\Collection + */ + protected function parseQueues($queues) + { + return collect(explode(',', $queues))->map(function ($queue) { + [$connection, $queue] = array_pad(explode(':', $queue, 2), 2, null); + + if (! isset($queue)) { + $queue = $connection; + $connection = $this->laravel['config']['queue.default']; + } + + return [ + 'connection' => $connection, + 'queue' => $queue, + 'size' => $size = $this->manager->connection($connection)->size($queue), + 'status' => $size >= $this->option('max') ? '<fg=red>ALERT</>' : 'OK', + ]; + }); + } + + /** + * Display the failed jobs in the console. + * + * @param \Illuminate\Support\Collection $queues + * @return void + */ + protected function displaySizes(Collection $queues) + { + $this->table($this->headers, $queues); + } + + /** + * Fire the monitoring events. + * + * @param \Illuminate\Support\Collection $queues + * @return void + */ + protected function dispatchEvents(Collection $queues) + { + foreach ($queues as $queue) { + if ($queue['status'] == 'OK') { + continue; + } + + $this->events->dispatch( + new QueueBusy( + $queue['connection'], + $queue['queue'], + $queue['size'], + ) + ); + } + } +} diff --git a/src/Illuminate/Queue/Console/PruneBatchesCommand.php b/src/Illuminate/Queue/Console/PruneBatchesCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..59ea3887b8abf5fab4df451d8f7117c4daadce70 --- /dev/null +++ b/src/Illuminate/Queue/Console/PruneBatchesCommand.php @@ -0,0 +1,56 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Carbon\Carbon; +use Illuminate\Bus\BatchRepository; +use Illuminate\Bus\DatabaseBatchRepository; +use Illuminate\Bus\PrunableBatchRepository; +use Illuminate\Console\Command; + +class PruneBatchesCommand extends Command +{ + /** + * The console command signature. + * + * @var string + */ + protected $signature = 'queue:prune-batches + {--hours=24 : The number of hours to retain batch data} + {--unfinished= : The number of hours to retain unfinished batch data }'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Prune stale entries from the batches database'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $repository = $this->laravel[BatchRepository::class]; + + $count = 0; + + if ($repository instanceof PrunableBatchRepository) { + $count = $repository->prune(Carbon::now()->subHours($this->option('hours'))); + } + + $this->info("{$count} entries deleted!"); + + if ($unfinished = $this->option('unfinished')) { + $count = 0; + + if ($repository instanceof DatabaseBatchRepository) { + $count = $repository->pruneUnfinished(Carbon::now()->subHours($this->option('unfinished'))); + } + + $this->info("{$count} unfinished entries deleted!"); + } + } +} diff --git a/src/Illuminate/Queue/Console/PruneFailedJobsCommand.php b/src/Illuminate/Queue/Console/PruneFailedJobsCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..f82d9be3b9555402f2a91c2e86b024190ad08f3b --- /dev/null +++ b/src/Illuminate/Queue/Console/PruneFailedJobsCommand.php @@ -0,0 +1,47 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Carbon\Carbon; +use Illuminate\Console\Command; +use Illuminate\Queue\Failed\PrunableFailedJobProvider; + +class PruneFailedJobsCommand extends Command +{ + /** + * The console command signature. + * + * @var string + */ + protected $signature = 'queue:prune-failed + {--hours=24 : The number of hours to retain failed jobs data}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Prune stale entries from the failed jobs table'; + + /** + * Execute the console command. + * + * @return int|null + */ + public function handle() + { + $failer = $this->laravel['queue.failer']; + + $count = 0; + + if ($failer instanceof PrunableFailedJobProvider) { + $count = $failer->prune(Carbon::now()->subHours($this->option('hours'))); + } else { + $this->error('The ['.class_basename($failer).'] failed job storage driver does not support pruning.'); + + return 1; + } + + $this->info("{$count} entries deleted!"); + } +} diff --git a/src/Illuminate/Queue/Console/RetryBatchCommand.php b/src/Illuminate/Queue/Console/RetryBatchCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..828278a48b9227698bc72bcebc9c6fd40cdb1c20 --- /dev/null +++ b/src/Illuminate/Queue/Console/RetryBatchCommand.php @@ -0,0 +1,47 @@ +<?php + +namespace Illuminate\Queue\Console; + +use Illuminate\Bus\BatchRepository; +use Illuminate\Console\Command; + +class RetryBatchCommand extends Command +{ + /** + * The console command signature. + * + * @var string + */ + protected $signature = 'queue:retry-batch {id : The ID of the batch whose failed jobs should be retried}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Retry the failed jobs for a batch'; + + /** + * Execute the console command. + * + * @return int|null + */ + public function handle() + { + $batch = $this->laravel[BatchRepository::class]->find($id = $this->argument('id')); + + if (! $batch) { + $this->error("Unable to find a batch with ID [{$id}]."); + + return 1; + } elseif (empty($batch->failedJobIds)) { + $this->error('The given batch does not contain any failed jobs.'); + + return 1; + } + + foreach ($batch->failedJobIds as $failedJobId) { + $this->call('queue:retry', ['id' => $failedJobId]); + } + } +} diff --git a/src/Illuminate/Queue/Console/RetryCommand.php b/src/Illuminate/Queue/Console/RetryCommand.php index c218ed48f98b5e1d7f00df172ee7206147eda4eb..dbd2e7acc970dd3e65438a867f241016fcd9c7df 100644 --- a/src/Illuminate/Queue/Console/RetryCommand.php +++ b/src/Illuminate/Queue/Console/RetryCommand.php @@ -2,8 +2,13 @@ namespace Illuminate\Queue\Console; +use DateTimeInterface; use Illuminate\Console\Command; +use Illuminate\Contracts\Encryption\Encrypter; +use Illuminate\Queue\Events\JobRetryRequested; use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use RuntimeException; class RetryCommand extends Command { @@ -12,7 +17,10 @@ class RetryCommand extends Command * * @var string */ - protected $signature = 'queue:retry {id* : The ID of the failed job or "all" to retry all jobs}'; + protected $signature = 'queue:retry + {id?* : The ID of the failed job or "all" to retry all jobs} + {--queue= : Retry all of the failed jobs for the specified queue} + {--range=* : Range of job IDs (numeric) to be retried}'; /** * The console command description. @@ -34,6 +42,8 @@ class RetryCommand extends Command if (is_null($job)) { $this->error("Unable to find failed job with ID [{$id}]."); } else { + $this->laravel['events']->dispatch(new JobRetryRequested($job)); + $this->retryJob($job); $this->info("The failed job [{$id}] has been pushed back onto the queue!"); @@ -53,7 +63,54 @@ class RetryCommand extends Command $ids = (array) $this->argument('id'); if (count($ids) === 1 && $ids[0] === 'all') { - $ids = Arr::pluck($this->laravel['queue.failer']->all(), 'id'); + return Arr::pluck($this->laravel['queue.failer']->all(), 'id'); + } + + if ($queue = $this->option('queue')) { + return $this->getJobIdsByQueue($queue); + } + + if ($ranges = (array) $this->option('range')) { + $ids = array_merge($ids, $this->getJobIdsByRanges($ranges)); + } + + return array_values(array_filter(array_unique($ids))); + } + + /** + * Get the job IDs by queue, if applicable. + * + * @param string $queue + * @return array + */ + protected function getJobIdsByQueue($queue) + { + $ids = collect($this->laravel['queue.failer']->all()) + ->where('queue', $queue) + ->pluck('id') + ->toArray(); + + if (count($ids) === 0) { + $this->error("Unable to find failed jobs for queue [{$queue}]."); + } + + return $ids; + } + + /** + * Get the job IDs ranges, if applicable. + * + * @param array $ranges + * @return array + */ + protected function getJobIdsByRanges(array $ranges) + { + $ids = []; + + foreach ($ranges as $range) { + if (preg_match('/^[0-9]+\-[0-9]+$/', $range)) { + $ids = array_merge($ids, range(...explode('-', $range))); + } } return $ids; @@ -68,14 +125,14 @@ class RetryCommand extends Command protected function retryJob($job) { $this->laravel['queue']->connection($job->connection)->pushRaw( - $this->resetAttempts($job->payload), $job->queue + $this->refreshRetryUntil($this->resetAttempts($job->payload)), $job->queue ); } /** * Reset the payload attempts. * - * Applicable to Redis jobs which store attempts in their payload. + * Applicable to Redis and other jobs which store attempts in their payload. * * @param string $payload * @return string @@ -90,4 +147,41 @@ class RetryCommand extends Command return json_encode($payload); } + + /** + * Refresh the "retry until" timestamp for the job. + * + * @param string $payload + * @return string + * + * @throws \RuntimeException + */ + protected function refreshRetryUntil($payload) + { + $payload = json_decode($payload, true); + + if (! isset($payload['data']['command'])) { + return json_encode($payload); + } + + if (Str::startsWith($payload['data']['command'], 'O:')) { + $instance = unserialize($payload['data']['command']); + } elseif ($this->laravel->bound(Encrypter::class)) { + $instance = unserialize($this->laravel->make(Encrypter::class)->decrypt($payload['data']['command'])); + } + + if (! isset($instance)) { + throw new RuntimeException('Unable to extract job payload.'); + } + + if (is_object($instance) && ! $instance instanceof \__PHP_Incomplete_Class && method_exists($instance, 'retryUntil')) { + $retryUntil = $instance->retryUntil(); + + $payload['retryUntil'] = $retryUntil instanceof DateTimeInterface + ? $retryUntil->getTimestamp() + : $retryUntil; + } + + return json_encode($payload); + } } diff --git a/src/Illuminate/Queue/Console/WorkCommand.php b/src/Illuminate/Queue/Console/WorkCommand.php index 0b9e7503dfadef913c96f2d77a9c95cc9dc9cb3d..da9176be406301350fddc8ed489fa8d7f5a50e39 100644 --- a/src/Illuminate/Queue/Console/WorkCommand.php +++ b/src/Illuminate/Queue/Console/WorkCommand.php @@ -21,14 +21,19 @@ class WorkCommand extends Command */ protected $signature = 'queue:work {connection? : The name of the queue connection to work} + {--name=default : The name of the worker} {--queue= : The names of the queues to work} {--daemon : Run the worker in daemon mode (Deprecated)} {--once : Only process the next job on the queue} {--stop-when-empty : Stop when the queue is empty} - {--delay=0 : The number of seconds to delay failed jobs} + {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} + {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} + {--max-jobs=0 : The number of jobs to process before stopping} + {--max-time=0 : The maximum number of seconds the worker should run} {--force : Force the worker to run even in maintenance mode} {--memory=128 : The memory limit in megabytes} {--sleep=3 : Number of seconds to sleep when no job is available} + {--rest=0 : Number of seconds to rest between jobs} {--timeout=60 : The number of seconds a child process can run} {--tries=1 : Number of times to attempt a job before logging it failed}'; @@ -71,7 +76,7 @@ class WorkCommand extends Command /** * Execute the console command. * - * @return void + * @return int|null */ public function handle() { @@ -92,7 +97,7 @@ class WorkCommand extends Command // connection being run for the queue operation currently being executed. $queue = $this->getQueue($connection); - $this->runWorker( + return $this->runWorker( $connection, $queue ); } @@ -102,13 +107,13 @@ class WorkCommand extends Command * * @param string $connection * @param string $queue - * @return array + * @return int|null */ protected function runWorker($connection, $queue) { - $this->worker->setCache($this->cache); - - return $this->worker->{$this->option('once') ? 'runNextJob' : 'daemon'}( + return $this->worker->setName($this->option('name')) + ->setCache($this->cache) + ->{$this->option('once') ? 'runNextJob' : 'daemon'}( $connection, $queue, $this->gatherWorkerOptions() ); } @@ -121,10 +126,17 @@ class WorkCommand extends Command protected function gatherWorkerOptions() { return new WorkerOptions( - $this->option('delay'), $this->option('memory'), - $this->option('timeout'), $this->option('sleep'), - $this->option('tries'), $this->option('force'), - $this->option('stop-when-empty') + $this->option('name'), + max($this->option('backoff'), $this->option('delay')), + $this->option('memory'), + $this->option('timeout'), + $this->option('sleep'), + $this->option('tries'), + $this->option('force'), + $this->option('stop-when-empty'), + $this->option('max-jobs'), + $this->option('max-time'), + $this->option('rest') ); } @@ -196,8 +208,10 @@ class WorkCommand extends Command protected function logFailedJob(JobFailed $event) { $this->laravel['queue.failer']->log( - $event->connectionName, $event->job->getQueue(), - $event->job->getRawBody(), $event->exception + $event->connectionName, + $event->job->getQueue(), + $event->job->getRawBody(), + $event->exception ); } diff --git a/src/Illuminate/Queue/Console/stubs/batches.stub b/src/Illuminate/Queue/Console/stubs/batches.stub new file mode 100644 index 0000000000000000000000000000000000000000..d4fa380c762492c1eac888b622a24aa8f739a58d --- /dev/null +++ b/src/Illuminate/Queue/Console/stubs/batches.stub @@ -0,0 +1,39 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class Create{{tableClassName}}Table extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('{{table}}', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->text('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('{{table}}'); + } +} diff --git a/src/Illuminate/Queue/Console/stubs/failed_jobs.stub b/src/Illuminate/Queue/Console/stubs/failed_jobs.stub index a7e612f8348fd2588b2ca3c90bd65ee8d537c191..6179e7f2f3785632278ec5885da8398bf885eb8e 100644 --- a/src/Illuminate/Queue/Console/stubs/failed_jobs.stub +++ b/src/Illuminate/Queue/Console/stubs/failed_jobs.stub @@ -14,7 +14,8 @@ class Create{{tableClassName}}Table extends Migration public function up() { Schema::create('{{table}}', function (Blueprint $table) { - $table->bigIncrements('id'); + $table->id(); + $table->string('uuid')->unique(); $table->text('connection'); $table->text('queue'); $table->longText('payload'); diff --git a/src/Illuminate/Queue/DatabaseQueue.php b/src/Illuminate/Queue/DatabaseQueue.php index aa52e8d57fede1bde030e280925d98eff9dac7b5..a1d3f085bea2dfafe9b715dc85c706e67444a3e7 100644 --- a/src/Illuminate/Queue/DatabaseQueue.php +++ b/src/Illuminate/Queue/DatabaseQueue.php @@ -2,14 +2,16 @@ namespace Illuminate\Queue; +use Illuminate\Contracts\Queue\ClearableQueue; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Database\Connection; use Illuminate\Queue\Jobs\DatabaseJob; use Illuminate\Queue\Jobs\DatabaseJobRecord; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use PDO; -class DatabaseQueue extends Queue implements QueueContract +class DatabaseQueue extends Queue implements QueueContract, ClearableQueue { /** * The database connection instance. @@ -46,14 +48,20 @@ class DatabaseQueue extends Queue implements QueueContract * @param string $table * @param string $default * @param int $retryAfter + * @param bool $dispatchAfterCommit * @return void */ - public function __construct(Connection $database, $table, $default = 'default', $retryAfter = 60) + public function __construct(Connection $database, + $table, + $default = 'default', + $retryAfter = 60, + $dispatchAfterCommit = false) { $this->table = $table; $this->default = $default; $this->database = $database; $this->retryAfter = $retryAfter; + $this->dispatchAfterCommit = $dispatchAfterCommit; } /** @@ -79,9 +87,15 @@ class DatabaseQueue extends Queue implements QueueContract */ public function push($job, $data = '', $queue = null) { - return $this->pushToDatabase($queue, $this->createPayload( - $job, $this->getQueue($queue), $data - )); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + null, + function ($payload, $queue) { + return $this->pushToDatabase($queue, $payload); + } + ); } /** @@ -108,9 +122,15 @@ class DatabaseQueue extends Queue implements QueueContract */ public function later($delay, $job, $data = '', $queue = null) { - return $this->pushToDatabase($queue, $this->createPayload( - $job, $this->getQueue($queue), $data - ), $delay); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + $delay, + function ($payload, $queue, $delay) { + return $this->pushToDatabase($queue, $payload, $delay); + } + ); } /** @@ -190,7 +210,7 @@ class DatabaseQueue extends Queue implements QueueContract * @param string|null $queue * @return \Illuminate\Contracts\Queue\Job|null * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function pop($queue = null) { @@ -232,10 +252,16 @@ class DatabaseQueue extends Queue implements QueueContract protected function getLockForPopping() { $databaseEngine = $this->database->getPdo()->getAttribute(PDO::ATTR_DRIVER_NAME); - $databaseVersion = $this->database->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + $databaseVersion = $this->database->getConfig('version') ?? $this->database->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + + if (Str::of($databaseVersion)->contains('MariaDB')) { + $databaseEngine = 'mariadb'; + $databaseVersion = Str::before(Str::after($databaseVersion, '5.5.5-'), '-'); + } - if ($databaseEngine == 'mysql' && ! strpos($databaseVersion, 'MariaDB') && version_compare($databaseVersion, '8.0.1', '>=') || - $databaseEngine == 'pgsql' && version_compare($databaseVersion, '9.5', '>=')) { + if (($databaseEngine === 'mysql' && version_compare($databaseVersion, '8.0.1', '>=')) || + ($databaseEngine === 'mariadb' && version_compare($databaseVersion, '10.6.0', '>=')) || + ($databaseEngine === 'pgsql' && version_compare($databaseVersion, '9.5', '>='))) { return 'FOR UPDATE SKIP LOCKED'; } @@ -310,7 +336,7 @@ class DatabaseQueue extends Queue implements QueueContract * @param string $id * @return void * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function deleteReserved($queue, $id) { @@ -321,6 +347,38 @@ class DatabaseQueue extends Queue implements QueueContract }); } + /** + * Delete a reserved job from the reserved queue and release it. + * + * @param string $queue + * @param \Illuminate\Queue\Jobs\DatabaseJob $job + * @param int $delay + * @return void + */ + public function deleteAndRelease($queue, $job, $delay) + { + $this->database->transaction(function () use ($queue, $job, $delay) { + if ($this->database->table($this->table)->lockForUpdate()->find($job->getJobId())) { + $this->database->table($this->table)->where('id', $job->getJobId())->delete(); + } + + $this->release($queue, $job->getJobRecord(), $delay); + }); + } + + /** + * Delete all of the jobs from the queue. + * + * @param string $queue + * @return int + */ + public function clear($queue) + { + return $this->database->table($this->table) + ->where('queue', $this->getQueue($queue)) + ->delete(); + } + /** * Get the queue or return the default. * diff --git a/src/Illuminate/Queue/Events/JobExceptionOccurred.php b/src/Illuminate/Queue/Events/JobExceptionOccurred.php index dc7940e1ed5460bc0eb4e73f33283d54f6699304..4bdf39226bf7b5d0e7241cb82d79707d96b6e139 100644 --- a/src/Illuminate/Queue/Events/JobExceptionOccurred.php +++ b/src/Illuminate/Queue/Events/JobExceptionOccurred.php @@ -21,7 +21,7 @@ class JobExceptionOccurred /** * The exception instance. * - * @var \Exception + * @var \Throwable */ public $exception; @@ -30,7 +30,7 @@ class JobExceptionOccurred * * @param string $connectionName * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $exception + * @param \Throwable $exception * @return void */ public function __construct($connectionName, $job, $exception) diff --git a/src/Illuminate/Queue/Events/JobFailed.php b/src/Illuminate/Queue/Events/JobFailed.php index 49b84f7be5a45a6f8096a814849746660b022351..d973a5039ed844b1bac6390462dc7a0f94100881 100644 --- a/src/Illuminate/Queue/Events/JobFailed.php +++ b/src/Illuminate/Queue/Events/JobFailed.php @@ -21,7 +21,7 @@ class JobFailed /** * The exception that caused the job to fail. * - * @var \Exception + * @var \Throwable */ public $exception; @@ -30,7 +30,7 @@ class JobFailed * * @param string $connectionName * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $exception + * @param \Throwable $exception * @return void */ public function __construct($connectionName, $job, $exception) diff --git a/src/Illuminate/Queue/Events/JobQueued.php b/src/Illuminate/Queue/Events/JobQueued.php new file mode 100644 index 0000000000000000000000000000000000000000..c91d140959632744a70812ab1d00598de9d3f12d --- /dev/null +++ b/src/Illuminate/Queue/Events/JobQueued.php @@ -0,0 +1,42 @@ +<?php + +namespace Illuminate\Queue\Events; + +class JobQueued +{ + /** + * The connection name. + * + * @var string + */ + public $connectionName; + + /** + * The job ID. + * + * @var string|int|null + */ + public $id; + + /** + * The job instance. + * + * @var \Closure|string|object + */ + public $job; + + /** + * Create a new event instance. + * + * @param string $connectionName + * @param string|int|null $id + * @param \Closure|string|object $job + * @return void + */ + public function __construct($connectionName, $id, $job) + { + $this->connectionName = $connectionName; + $this->id = $id; + $this->job = $job; + } +} diff --git a/src/Illuminate/Queue/Events/JobRetryRequested.php b/src/Illuminate/Queue/Events/JobRetryRequested.php new file mode 100644 index 0000000000000000000000000000000000000000..9b9809f639503b7c89244d328d592835cacbfd1f --- /dev/null +++ b/src/Illuminate/Queue/Events/JobRetryRequested.php @@ -0,0 +1,45 @@ +<?php + +namespace Illuminate\Queue\Events; + +class JobRetryRequested +{ + /** + * The job instance. + * + * @var \stdClass + */ + public $job; + + /** + * The decoded job payload. + * + * @var array|null + */ + protected $payload = null; + + /** + * Create a new event instance. + * + * @param \stdClass $job + * @return void + */ + public function __construct($job) + { + $this->job = $job; + } + + /** + * The job payload. + * + * @return array + */ + public function payload() + { + if (is_null($this->payload)) { + $this->payload = json_decode($this->job->payload, true); + } + + return $this->payload; + } +} diff --git a/src/Illuminate/Queue/Events/QueueBusy.php b/src/Illuminate/Queue/Events/QueueBusy.php new file mode 100644 index 0000000000000000000000000000000000000000..684dec4ea08a40b5b46f86b0733f098693fe7a98 --- /dev/null +++ b/src/Illuminate/Queue/Events/QueueBusy.php @@ -0,0 +1,42 @@ +<?php + +namespace Illuminate\Queue\Events; + +class QueueBusy +{ + /** + * The connection name. + * + * @var string + */ + public $connection; + + /** + * The queue name. + * + * @var string + */ + public $queue; + + /** + * The size of the queue. + * + * @var int + */ + public $size; + + /** + * Create a new event instance. + * + * @param string $connection + * @param string $queue + * @param int $size + * @return void + */ + public function __construct($connection, $queue, $size) + { + $this->connection = $connection; + $this->queue = $queue; + $this->size = $size; + } +} diff --git a/src/Illuminate/Queue/Failed/DatabaseFailedJobProvider.php b/src/Illuminate/Queue/Failed/DatabaseFailedJobProvider.php index 6b7695a3c4c6408204486edd3655115eabfb7106..a4d98e03277a2b2778964354ba538ef1484b26f6 100644 --- a/src/Illuminate/Queue/Failed/DatabaseFailedJobProvider.php +++ b/src/Illuminate/Queue/Failed/DatabaseFailedJobProvider.php @@ -2,10 +2,11 @@ namespace Illuminate\Queue\Failed; +use DateTimeInterface; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Support\Facades\Date; -class DatabaseFailedJobProvider implements FailedJobProviderInterface +class DatabaseFailedJobProvider implements FailedJobProviderInterface, PrunableFailedJobProvider { /** * The connection resolver implementation. @@ -49,7 +50,7 @@ class DatabaseFailedJobProvider implements FailedJobProviderInterface * @param string $connection * @param string $queue * @param string $payload - * @param \Exception $exception + * @param \Throwable $exception * @return int|null */ public function log($connection, $queue, $payload, $exception) @@ -105,6 +106,27 @@ class DatabaseFailedJobProvider implements FailedJobProviderInterface $this->getTable()->delete(); } + /** + * Prune all of the entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function prune(DateTimeInterface $before) + { + $query = $this->getTable()->where('failed_at', '<', $before); + + $totalDeleted = 0; + + do { + $deleted = $query->take(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + /** * Get a new query builder instance for the table. * diff --git a/src/Illuminate/Queue/Failed/DatabaseUuidFailedJobProvider.php b/src/Illuminate/Queue/Failed/DatabaseUuidFailedJobProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..e9520524d2f7ee91631a2e5a5464d084817d3247 --- /dev/null +++ b/src/Illuminate/Queue/Failed/DatabaseUuidFailedJobProvider.php @@ -0,0 +1,152 @@ +<?php + +namespace Illuminate\Queue\Failed; + +use DateTimeInterface; +use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Support\Facades\Date; + +class DatabaseUuidFailedJobProvider implements FailedJobProviderInterface, PrunableFailedJobProvider +{ + /** + * The connection resolver implementation. + * + * @var \Illuminate\Database\ConnectionResolverInterface + */ + protected $resolver; + + /** + * The database connection name. + * + * @var string + */ + protected $database; + + /** + * The database table. + * + * @var string + */ + protected $table; + + /** + * Create a new database failed job provider. + * + * @param \Illuminate\Database\ConnectionResolverInterface $resolver + * @param string $database + * @param string $table + * @return void + */ + public function __construct(ConnectionResolverInterface $resolver, $database, $table) + { + $this->table = $table; + $this->resolver = $resolver; + $this->database = $database; + } + + /** + * Log a failed job into storage. + * + * @param string $connection + * @param string $queue + * @param string $payload + * @param \Throwable $exception + * @return string|null + */ + public function log($connection, $queue, $payload, $exception) + { + $this->getTable()->insert([ + 'uuid' => $uuid = json_decode($payload, true)['uuid'], + 'connection' => $connection, + 'queue' => $queue, + 'payload' => $payload, + 'exception' => (string) $exception, + 'failed_at' => Date::now(), + ]); + + return $uuid; + } + + /** + * Get a list of all of the failed jobs. + * + * @return array + */ + public function all() + { + return $this->getTable()->orderBy('id', 'desc')->get()->map(function ($record) { + $record->id = $record->uuid; + unset($record->uuid); + + return $record; + })->all(); + } + + /** + * Get a single failed job. + * + * @param mixed $id + * @return object|null + */ + public function find($id) + { + if ($record = $this->getTable()->where('uuid', $id)->first()) { + $record->id = $record->uuid; + unset($record->uuid); + } + + return $record; + } + + /** + * Delete a single failed job from storage. + * + * @param mixed $id + * @return bool + */ + public function forget($id) + { + return $this->getTable()->where('uuid', $id)->delete() > 0; + } + + /** + * Flush all of the failed jobs from storage. + * + * @return void + */ + public function flush() + { + $this->getTable()->delete(); + } + + /** + * Prune all of the entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function prune(DateTimeInterface $before) + { + $query = $this->getTable()->where('failed_at', '<', $before); + + $totalDeleted = 0; + + do { + $deleted = $query->take(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + + /** + * Get a new query builder instance for the table. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function getTable() + { + return $this->resolver->connection($this->database)->table($this->table); + } +} diff --git a/src/Illuminate/Queue/Failed/DynamoDbFailedJobProvider.php b/src/Illuminate/Queue/Failed/DynamoDbFailedJobProvider.php index 97740cb766fe70fa8a7251d7363f0ad2e724907e..7b88b2394ee5a81ff3a2ec1510ece92e888cc0f7 100644 --- a/src/Illuminate/Queue/Failed/DynamoDbFailedJobProvider.php +++ b/src/Illuminate/Queue/Failed/DynamoDbFailedJobProvider.php @@ -7,7 +7,6 @@ use DateTimeInterface; use Exception; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; -use Illuminate\Support\Str; class DynamoDbFailedJobProvider implements FailedJobProviderInterface { @@ -53,12 +52,12 @@ class DynamoDbFailedJobProvider implements FailedJobProviderInterface * @param string $connection * @param string $queue * @param string $payload - * @param \Exception $exception + * @param \Throwable $exception * @return string|int|null */ public function log($connection, $queue, $payload, $exception) { - $id = (string) Str::orderedUuid(); + $id = json_decode($payload, true)['uuid']; $failedAt = Date::now(); @@ -96,7 +95,9 @@ class DynamoDbFailedJobProvider implements FailedJobProviderInterface 'ScanIndexForward' => false, ]); - return collect($results['Items'])->map(function ($result) { + return collect($results['Items'])->sortByDesc(function ($result) { + return (int) $result['failed_at']['N']; + })->map(function ($result) { return (object) [ 'id' => $result['uuid']['S'], 'connection' => $result['connection']['S'], diff --git a/src/Illuminate/Queue/Failed/FailedJobProviderInterface.php b/src/Illuminate/Queue/Failed/FailedJobProviderInterface.php index 8f73226dd04b3865c60927fa4a238937866f3bfc..1a11fc0b5806b2f4e47db846b146312234f0e193 100644 --- a/src/Illuminate/Queue/Failed/FailedJobProviderInterface.php +++ b/src/Illuminate/Queue/Failed/FailedJobProviderInterface.php @@ -10,7 +10,7 @@ interface FailedJobProviderInterface * @param string $connection * @param string $queue * @param string $payload - * @param \Exception $exception + * @param \Throwable $exception * @return string|int|null */ public function log($connection, $queue, $payload, $exception); diff --git a/src/Illuminate/Queue/Failed/NullFailedJobProvider.php b/src/Illuminate/Queue/Failed/NullFailedJobProvider.php index 4760d5874d869696601f05194a83725ff7e6f20f..06f3e078603d64861738eba3296b632e4a2a452f 100644 --- a/src/Illuminate/Queue/Failed/NullFailedJobProvider.php +++ b/src/Illuminate/Queue/Failed/NullFailedJobProvider.php @@ -10,7 +10,7 @@ class NullFailedJobProvider implements FailedJobProviderInterface * @param string $connection * @param string $queue * @param string $payload - * @param \Exception $exception + * @param \Throwable $exception * @return int|null */ public function log($connection, $queue, $payload, $exception) diff --git a/src/Illuminate/Queue/Failed/PrunableFailedJobProvider.php b/src/Illuminate/Queue/Failed/PrunableFailedJobProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..ea505b0cdfa49cc11d7ffb2040bd90ff89e8ecc4 --- /dev/null +++ b/src/Illuminate/Queue/Failed/PrunableFailedJobProvider.php @@ -0,0 +1,16 @@ +<?php + +namespace Illuminate\Queue\Failed; + +use DateTimeInterface; + +interface PrunableFailedJobProvider +{ + /** + * Prune all of the entries older than the given date. + * + * @param \DateTimeInterface $before + * @return int + */ + public function prune(DateTimeInterface $before); +} diff --git a/src/Illuminate/Queue/InteractsWithQueue.php b/src/Illuminate/Queue/InteractsWithQueue.php index 494a9825fb32f9ebe8176588d5ebcfca9db1c70e..159dfff96d1bc5b1fe7250aabf9763ec27b8b4f4 100644 --- a/src/Illuminate/Queue/InteractsWithQueue.php +++ b/src/Illuminate/Queue/InteractsWithQueue.php @@ -11,7 +11,7 @@ trait InteractsWithQueue * * @var \Illuminate\Contracts\Queue\Job */ - protected $job; + public $job; /** * Get the number of times the job has been attempted. diff --git a/src/Illuminate/Queue/Jobs/DatabaseJob.php b/src/Illuminate/Queue/Jobs/DatabaseJob.php index 18f3c0696b770dd9216977fb24e9db408549a105..66332e27f566b80aee16723815429480f864f26b 100644 --- a/src/Illuminate/Queue/Jobs/DatabaseJob.php +++ b/src/Illuminate/Queue/Jobs/DatabaseJob.php @@ -45,15 +45,13 @@ class DatabaseJob extends Job implements JobContract * Release the job back into the queue. * * @param int $delay - * @return mixed + * @return void */ public function release($delay = 0) { parent::release($delay); - $this->delete(); - - return $this->database->release($this->queue, $this->job, $delay); + $this->database->deleteAndRelease($this->queue, $this, $delay); } /** @@ -97,4 +95,14 @@ class DatabaseJob extends Job implements JobContract { return $this->job->payload; } + + /** + * Get the database job record. + * + * @return \Illuminate\Queue\Jobs\DatabaseJobRecord + */ + public function getJobRecord() + { + return $this->job; + } } diff --git a/src/Illuminate/Queue/Jobs/Job.php b/src/Illuminate/Queue/Jobs/Job.php index 7795b96c05e04ec7280d4adb0bc52b86456db967..28cd103e968c87d673a39bf988fedd79d0c63401 100755 --- a/src/Illuminate/Queue/Jobs/Job.php +++ b/src/Illuminate/Queue/Jobs/Job.php @@ -74,6 +74,16 @@ abstract class Job */ abstract public function getRawBody(); + /** + * Get the UUID of the job. + * + * @return string|null + */ + public function uuid() + { + return $this->payload()['uuid'] ?? null; + } + /** * Fire the job. * @@ -200,7 +210,7 @@ abstract class Job [$class, $method] = JobName::parse($payload['job']); if (method_exists($this->instance = $this->resolve($class), 'failed')) { - $this->instance->failed($payload['data'], $e); + $this->instance->failed($payload['data'], $e, $payload['uuid'] ?? ''); } } @@ -246,13 +256,33 @@ abstract class Job } /** - * Get the number of seconds to delay a failed job before retrying it. + * Get the number of times to attempt a job after an exception. + * + * @return int|null + */ + public function maxExceptions() + { + return $this->payload()['maxExceptions'] ?? null; + } + + /** + * Determine if the job should fail when it timeouts. + * + * @return bool + */ + public function shouldFailOnTimeout() + { + return $this->payload()['failOnTimeout'] ?? false; + } + + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. * * @return int|null */ - public function delaySeconds() + public function backoff() { - return $this->payload()['delay'] ?? null; + return $this->payload()['backoff'] ?? $this->payload()['delay'] ?? null; } /** @@ -270,9 +300,9 @@ abstract class Job * * @return int|null */ - public function timeoutAt() + public function retryUntil() { - return $this->payload()['timeoutAt'] ?? null; + return $this->payload()['retryUntil'] ?? $this->payload()['timeoutAt'] ?? null; } /** diff --git a/src/Illuminate/Queue/Jobs/RedisJob.php b/src/Illuminate/Queue/Jobs/RedisJob.php index 277516a0bcc3ba85db3ceb6ab39adb8e35cd3714..0fbc327ebcfc5e0eec91c0ed4b10a64f9963cee9 100644 --- a/src/Illuminate/Queue/Jobs/RedisJob.php +++ b/src/Illuminate/Queue/Jobs/RedisJob.php @@ -110,7 +110,7 @@ class RedisJob extends Job implements JobContract /** * Get the job identifier. * - * @return string + * @return string|null */ public function getJobId() { diff --git a/src/Illuminate/Queue/Listener.php b/src/Illuminate/Queue/Listener.php index 885d683bd2fe732c32c9b88343a937e5255725b4..cbead01bcdb23bb738ee79c1959d611e22f98edd 100755 --- a/src/Illuminate/Queue/Listener.php +++ b/src/Illuminate/Queue/Listener.php @@ -151,8 +151,9 @@ class Listener 'queue:work', $connection, '--once', + "--name={$options->name}", "--queue={$queue}", - "--delay={$options->delay}", + "--backoff={$options->backoff}", "--memory={$options->memory}", "--sleep={$options->sleep}", "--tries={$options->maxTries}", diff --git a/src/Illuminate/Queue/ListenerOptions.php b/src/Illuminate/Queue/ListenerOptions.php index 22da0cd9730acd2a5816a3cd9d190a5478f5a32e..c147f7afc26ccda10d38bc2dc736797d9ed72399 100644 --- a/src/Illuminate/Queue/ListenerOptions.php +++ b/src/Illuminate/Queue/ListenerOptions.php @@ -14,8 +14,9 @@ class ListenerOptions extends WorkerOptions /** * Create a new listener options instance. * + * @param string $name * @param string|null $environment - * @param int $delay + * @param int $backoff * @param int $memory * @param int $timeout * @param int $sleep @@ -23,10 +24,10 @@ class ListenerOptions extends WorkerOptions * @param bool $force * @return void */ - public function __construct($environment = null, $delay = 0, $memory = 128, $timeout = 60, $sleep = 3, $maxTries = 1, $force = false) + public function __construct($name = 'default', $environment = null, $backoff = 0, $memory = 128, $timeout = 60, $sleep = 3, $maxTries = 1, $force = false) { $this->environment = $environment; - parent::__construct($delay, $memory, $timeout, $sleep, $maxTries, $force); + parent::__construct($name, $backoff, $memory, $timeout, $sleep, $maxTries, $force); } } diff --git a/src/Illuminate/Queue/LuaScripts.php b/src/Illuminate/Queue/LuaScripts.php index c031140cf7324747b21590c0b85a1d08447f3229..fa278426bdbbb2529cc2f5215f703facfefd33a3 100644 --- a/src/Illuminate/Queue/LuaScripts.php +++ b/src/Illuminate/Queue/LuaScripts.php @@ -24,7 +24,7 @@ LUA; * Get the Lua script for pushing jobs onto the queue. * * KEYS[1] - The queue to push the job onto, for example: queues:foo - * KEYS[2] - The notification list fot the queue we are pushing jobs onto, for example: queues:foo:notify + * KEYS[2] - The notification list for the queue we are pushing jobs onto, for example: queues:foo:notify * ARGV[1] - The job payload * * @return string @@ -124,6 +124,25 @@ if(next(val) ~= nil) then end return val +LUA; + } + + /** + * Get the Lua script for removing all jobs from the queue. + * + * KEYS[1] - The name of the primary queue + * KEYS[2] - The name of the "delayed" queue + * KEYS[3] - The name of the "reserved" queue + * KEYS[4] - The name of the "notify" queue + * + * @return string + */ + public static function clear() + { + return <<<'LUA' +local size = redis.call('llen', KEYS[1]) + redis.call('zcard', KEYS[2]) + redis.call('zcard', KEYS[3]) +redis.call('del', KEYS[1], KEYS[2], KEYS[3], KEYS[4]) +return size LUA; } } diff --git a/src/Illuminate/Queue/Middleware/RateLimited.php b/src/Illuminate/Queue/Middleware/RateLimited.php new file mode 100644 index 0000000000000000000000000000000000000000..3dd1b435bdc9352bb98d42309034aaa8a065dfe5 --- /dev/null +++ b/src/Illuminate/Queue/Middleware/RateLimited.php @@ -0,0 +1,146 @@ +<?php + +namespace Illuminate\Queue\Middleware; + +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\RateLimiting\Unlimited; +use Illuminate\Container\Container; +use Illuminate\Support\Arr; + +class RateLimited +{ + /** + * The rate limiter instance. + * + * @var \Illuminate\Cache\RateLimiter + */ + protected $limiter; + + /** + * The name of the rate limiter. + * + * @var string + */ + protected $limiterName; + + /** + * Indicates if the job should be released if the limit is exceeded. + * + * @var bool + */ + public $shouldRelease = true; + + /** + * Create a new middleware instance. + * + * @param string $limiterName + * @return void + */ + public function __construct($limiterName) + { + $this->limiter = Container::getInstance()->make(RateLimiter::class); + + $this->limiterName = $limiterName; + } + + /** + * Process the job. + * + * @param mixed $job + * @param callable $next + * @return mixed + */ + public function handle($job, $next) + { + if (is_null($limiter = $this->limiter->limiter($this->limiterName))) { + return $next($job); + } + + $limiterResponse = call_user_func($limiter, $job); + + if ($limiterResponse instanceof Unlimited) { + return $next($job); + } + + return $this->handleJob( + $job, + $next, + collect(Arr::wrap($limiterResponse))->map(function ($limit) { + return (object) [ + 'key' => md5($this->limiterName.$limit->key), + 'maxAttempts' => $limit->maxAttempts, + 'decayMinutes' => $limit->decayMinutes, + ]; + })->all() + ); + } + + /** + * Handle a rate limited job. + * + * @param mixed $job + * @param callable $next + * @param array $limits + * @return mixed + */ + protected function handleJob($job, $next, array $limits) + { + foreach ($limits as $limit) { + if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { + return $this->shouldRelease + ? $job->release($this->getTimeUntilNextRetry($limit->key)) + : false; + } + + $this->limiter->hit($limit->key, $limit->decayMinutes * 60); + } + + return $next($job); + } + + /** + * Do not release the job back to the queue if the limit is exceeded. + * + * @return $this + */ + public function dontRelease() + { + $this->shouldRelease = false; + + return $this; + } + + /** + * Get the number of seconds that should elapse before the job is retried. + * + * @param string $key + * @return int + */ + protected function getTimeUntilNextRetry($key) + { + return $this->limiter->availableIn($key) + 3; + } + + /** + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep() + { + return [ + 'limiterName', + 'shouldRelease', + ]; + } + + /** + * Prepare the object after unserialization. + * + * @return void + */ + public function __wakeup() + { + $this->limiter = Container::getInstance()->make(RateLimiter::class); + } +} diff --git a/src/Illuminate/Queue/Middleware/RateLimitedWithRedis.php b/src/Illuminate/Queue/Middleware/RateLimitedWithRedis.php new file mode 100644 index 0000000000000000000000000000000000000000..e919786f27c699f004bff8b6fcc44292ad870077 --- /dev/null +++ b/src/Illuminate/Queue/Middleware/RateLimitedWithRedis.php @@ -0,0 +1,103 @@ +<?php + +namespace Illuminate\Queue\Middleware; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Redis\Factory as Redis; +use Illuminate\Redis\Limiters\DurationLimiter; +use Illuminate\Support\InteractsWithTime; + +class RateLimitedWithRedis extends RateLimited +{ + use InteractsWithTime; + + /** + * The Redis factory implementation. + * + * @var \Illuminate\Contracts\Redis\Factory + */ + protected $redis; + + /** + * The timestamp of the end of the current duration by key. + * + * @var array + */ + public $decaysAt = []; + + /** + * Create a new middleware instance. + * + * @param string $limiterName + * @return void + */ + public function __construct($limiterName) + { + parent::__construct($limiterName); + + $this->redis = Container::getInstance()->make(Redis::class); + } + + /** + * Handle a rate limited job. + * + * @param mixed $job + * @param callable $next + * @param array $limits + * @return mixed + */ + protected function handleJob($job, $next, array $limits) + { + foreach ($limits as $limit) { + if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decayMinutes)) { + return $this->shouldRelease + ? $job->release($this->getTimeUntilNextRetry($limit->key)) + : false; + } + } + + return $next($job); + } + + /** + * Determine if the given key has been "accessed" too many times. + * + * @param string $key + * @param int $maxAttempts + * @param int $decayMinutes + * @return bool + */ + protected function tooManyAttempts($key, $maxAttempts, $decayMinutes) + { + $limiter = new DurationLimiter( + $this->redis, $key, $maxAttempts, $decayMinutes * 60 + ); + + return tap(! $limiter->acquire(), function () use ($key, $limiter) { + $this->decaysAt[$key] = $limiter->decaysAt; + }); + } + + /** + * Get the number of seconds that should elapse before the job is retried. + * + * @param string $key + * @return int + */ + protected function getTimeUntilNextRetry($key) + { + return ($this->decaysAt[$key] - $this->currentTime()) + 3; + } + + /** + * Prepare the object after unserialization. + * + * @return void + */ + public function __wakeup() + { + parent::__wakeup(); + + $this->redis = Container::getInstance()->make(Redis::class); + } +} diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php new file mode 100644 index 0000000000000000000000000000000000000000..d289989c807b2ba73aba6a31c42af0bf658764e2 --- /dev/null +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php @@ -0,0 +1,202 @@ +<?php + +namespace Illuminate\Queue\Middleware; + +use Illuminate\Cache\RateLimiter; +use Illuminate\Container\Container; +use Throwable; + +class ThrottlesExceptions +{ + /** + * The developer specified key that the rate limiter should use. + * + * @var string + */ + protected $key; + + /** + * Indicates whether the throttle key should use the job's UUID. + * + * @var bool + */ + protected $byJob = false; + + /** + * The maximum number of attempts allowed before rate limiting applies. + * + * @var int + */ + protected $maxAttempts; + + /** + * The number of minutes until the maximum attempts are reset. + * + * @var int + */ + protected $decayMinutes; + + /** + * The number of minutes to wait before retrying the job after an exception. + * + * @var int + */ + protected $retryAfterMinutes = 0; + + /** + * The callback that determines if rate limiting should apply. + * + * @var callable + */ + protected $whenCallback; + + /** + * The prefix of the rate limiter key. + * + * @var string + */ + protected $prefix = 'laravel_throttles_exceptions:'; + + /** + * The rate limiter instance. + * + * @var \Illuminate\Cache\RateLimiter + */ + protected $limiter; + + /** + * Create a new middleware instance. + * + * @param int $maxAttempts + * @param int $decayMinutes + * @param string $key + * @return void + */ + public function __construct($maxAttempts = 10, $decayMinutes = 10) + { + $this->maxAttempts = $maxAttempts; + $this->decayMinutes = $decayMinutes; + } + + /** + * Process the job. + * + * @param mixed $job + * @param callable $next + * @return mixed + */ + public function handle($job, $next) + { + $this->limiter = Container::getInstance()->make(RateLimiter::class); + + if ($this->limiter->tooManyAttempts($jobKey = $this->getKey($job), $this->maxAttempts)) { + return $job->release($this->getTimeUntilNextRetry($jobKey)); + } + + try { + $next($job); + + $this->limiter->clear($jobKey); + } catch (Throwable $throwable) { + if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable)) { + throw $throwable; + } + + $this->limiter->hit($jobKey, $this->decayMinutes * 60); + + return $job->release($this->retryAfterMinutes * 60); + } + } + + /** + * Specify a callback that should determine if rate limiting behavior should apply. + * + * @param callable $callback + * @return $this + */ + public function when(callable $callback) + { + $this->whenCallback = $callback; + + return $this; + } + + /** + * Set the prefix of the rate limiter key. + * + * @param string $prefix + * @return $this + */ + public function withPrefix(string $prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * Specify the number of minutes a job should be delayed when it is released (before it has reached its max exceptions). + * + * @param int $backoff + * @return $this + */ + public function backoff($backoff) + { + $this->retryAfterMinutes = $backoff; + + return $this; + } + + /** + * Get the cache key associated for the rate limiter. + * + * @param mixed $job + * @return string + */ + protected function getKey($job) + { + if ($this->key) { + return $this->prefix.$this->key; + } elseif ($this->byJob) { + return $this->prefix.$job->job->uuid(); + } + + return $this->prefix.md5(get_class($job)); + } + + /** + * Set the value that the rate limiter should be keyed by. + * + * @param string $key + * @return $this + */ + public function by($key) + { + $this->key = $key; + + return $this; + } + + /** + * Indicate that the throttle key should use the job's UUID. + * + * @return $this + */ + public function byJob() + { + $this->byJob = true; + + return $this; + } + + /** + * Get the number of seconds that should elapse before the job is retried. + * + * @param string $key + * @return int + */ + protected function getTimeUntilNextRetry($key) + { + return $this->limiter->availableIn($key) + 3; + } +} diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php new file mode 100644 index 0000000000000000000000000000000000000000..38790e353e2d06160b0723cf2e9ef7d14870263c --- /dev/null +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php @@ -0,0 +1,62 @@ +<?php + +namespace Illuminate\Queue\Middleware; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Redis\Factory as Redis; +use Illuminate\Redis\Limiters\DurationLimiter; +use Illuminate\Support\InteractsWithTime; +use Throwable; + +class ThrottlesExceptionsWithRedis extends ThrottlesExceptions +{ + use InteractsWithTime; + + /** + * The Redis factory implementation. + * + * @var \Illuminate\Contracts\Redis\Factory + */ + protected $redis; + + /** + * The rate limiter instance. + * + * @var \Illuminate\Redis\Limiters\DurationLimiter + */ + protected $limiter; + + /** + * Process the job. + * + * @param mixed $job + * @param callable $next + * @return mixed + */ + public function handle($job, $next) + { + $this->redis = Container::getInstance()->make(Redis::class); + + $this->limiter = new DurationLimiter( + $this->redis, $this->getKey($job), $this->maxAttempts, $this->decayMinutes * 60 + ); + + if ($this->limiter->tooManyAttempts()) { + return $job->release($this->limiter->decaysAt - $this->currentTime()); + } + + try { + $next($job); + + $this->limiter->clear(); + } catch (Throwable $throwable) { + if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable)) { + throw $throwable; + } + + $this->limiter->acquire(); + + return $job->release($this->retryAfterMinutes * 60); + } + } +} diff --git a/src/Illuminate/Queue/Middleware/WithoutOverlapping.php b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php new file mode 100644 index 0000000000000000000000000000000000000000..c7989e653a57151bb2ecb25c5ee55f2d97d5e550 --- /dev/null +++ b/src/Illuminate/Queue/Middleware/WithoutOverlapping.php @@ -0,0 +1,141 @@ +<?php + +namespace Illuminate\Queue\Middleware; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Support\InteractsWithTime; + +class WithoutOverlapping +{ + use InteractsWithTime; + + /** + * The job's unique key used for preventing overlaps. + * + * @var string + */ + public $key; + + /** + * The number of seconds before a job should be available again if no lock was acquired. + * + * @var \DateTimeInterface|int|null + */ + public $releaseAfter; + + /** + * The number of seconds before the lock should expire. + * + * @var int + */ + public $expiresAfter; + + /** + * The prefix of the lock key. + * + * @var string + */ + public $prefix = 'laravel-queue-overlap:'; + + /** + * Create a new middleware instance. + * + * @param string $key + * @param \DateTimeInterface|int|null $releaseAfter + * @param \DateTimeInterface|int $expiresAfter + * @return void + */ + public function __construct($key = '', $releaseAfter = 0, $expiresAfter = 0) + { + $this->key = $key; + $this->releaseAfter = $releaseAfter; + $this->expiresAfter = $this->secondsUntil($expiresAfter); + } + + /** + * Process the job. + * + * @param mixed $job + * @param callable $next + * @return mixed + */ + public function handle($job, $next) + { + $lock = Container::getInstance()->make(Cache::class)->lock( + $this->getLockKey($job), $this->expiresAfter + ); + + if ($lock->get()) { + try { + $next($job); + } finally { + $lock->release(); + } + } elseif (! is_null($this->releaseAfter)) { + $job->release($this->releaseAfter); + } + } + + /** + * Set the delay (in seconds) to release the job back to the queue. + * + * @param \DateTimeInterface|int $releaseAfter + * @return $this + */ + public function releaseAfter($releaseAfter) + { + $this->releaseAfter = $releaseAfter; + + return $this; + } + + /** + * Do not release the job back to the queue if no lock can be acquired. + * + * @return $this + */ + public function dontRelease() + { + $this->releaseAfter = null; + + return $this; + } + + /** + * Set the maximum number of seconds that can elapse before the lock is released. + * + * @param \DateTimeInterface|int $expiresAfter + * @return $this + */ + public function expireAfter($expiresAfter) + { + $this->expiresAfter = $this->secondsUntil($expiresAfter); + + return $this; + } + + /** + * Set the prefix of the lock key. + * + * @param string $prefix + * @return $this + */ + public function withPrefix(string $prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * Get the lock key for the given job. + * + * @param mixed $job + * @return string + */ + public function getLockKey($job) + { + return $this->prefix.get_class($job).':'.$this->key; + } +} diff --git a/src/Illuminate/Queue/Queue.php b/src/Illuminate/Queue/Queue.php index d0dee0c0c28487dedc961aece40946df3c5aa0ca..5a00b55b0503bc858517e9b8f66ead1c90221dab 100755 --- a/src/Illuminate/Queue/Queue.php +++ b/src/Illuminate/Queue/Queue.php @@ -2,9 +2,15 @@ namespace Illuminate\Queue; +use Closure; use DateTimeInterface; use Illuminate\Container\Container; +use Illuminate\Contracts\Encryption\Encrypter; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Queue\Events\JobQueued; +use Illuminate\Support\Arr; use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Str; abstract class Queue { @@ -24,6 +30,13 @@ abstract class Queue */ protected $connectionName; + /** + * Indicates that jobs should be dispatched after all database transactions have committed. + * + * @return $this + */ + protected $dispatchAfterCommit; + /** * The create payload callbacks. * @@ -76,7 +89,7 @@ abstract class Queue /** * Create a payload string from the given job and data. * - * @param string|object $job + * @param \Closure|string|object $job * @param string $queue * @param mixed $data * @return string @@ -85,7 +98,11 @@ abstract class Queue */ protected function createPayload($job, $queue, $data = '') { - $payload = json_encode($this->createPayloadArray($job, $queue, $data)); + if ($job instanceof Closure) { + $job = CallQueuedClosure::create($job); + } + + $payload = json_encode($this->createPayloadArray($job, $queue, $data), \JSON_UNESCAPED_UNICODE); if (JSON_ERROR_NONE !== json_last_error()) { throw new InvalidPayloadException( @@ -121,23 +138,30 @@ abstract class Queue protected function createObjectPayload($job, $queue) { $payload = $this->withCreatePayloadHooks($queue, [ + 'uuid' => (string) Str::uuid(), 'displayName' => $this->getDisplayName($job), 'job' => 'Illuminate\Queue\CallQueuedHandler@call', 'maxTries' => $job->tries ?? null, - 'delay' => $this->getJobRetryDelay($job), + 'maxExceptions' => $job->maxExceptions ?? null, + 'failOnTimeout' => $job->failOnTimeout ?? false, + 'backoff' => $this->getJobBackoff($job), 'timeout' => $job->timeout ?? null, - 'timeoutAt' => $this->getJobExpiration($job), + 'retryUntil' => $this->getJobExpiration($job), 'data' => [ 'commandName' => $job, 'command' => $job, ], ]); + $command = $this->jobShouldBeEncrypted($job) && $this->container->bound(Encrypter::class) + ? $this->container[Encrypter::class]->encrypt(serialize(clone $job)) + : serialize(clone $job); + return array_merge($payload, [ - 'data' => [ + 'data' => array_merge($payload['data'], [ 'commandName' => get_class($job), - 'command' => serialize(clone $job), - ], + 'command' => $command, + ]), ]); } @@ -154,21 +178,26 @@ abstract class Queue } /** - * Get the retry delay for an object-based queue handler. + * Get the backoff for an object-based queue handler. * * @param mixed $job * @return mixed */ - public function getJobRetryDelay($job) + public function getJobBackoff($job) { - if (! method_exists($job, 'retryAfter') && ! isset($job->retryAfter)) { + if (! method_exists($job, 'backoff') && ! isset($job->backoff)) { return; } - $delay = $job->retryAfter ?? $job->retryAfter(); + if (is_null($backoff = $job->backoff ?? $job->backoff())) { + return; + } - return $delay instanceof DateTimeInterface - ? $this->secondsUntil($delay) : $delay; + return collect(Arr::wrap($backoff)) + ->map(function ($backoff) { + return $backoff instanceof DateTimeInterface + ? $this->secondsUntil($backoff) : $backoff; + })->implode(','); } /** @@ -179,16 +208,31 @@ abstract class Queue */ public function getJobExpiration($job) { - if (! method_exists($job, 'retryUntil') && ! isset($job->timeoutAt)) { + if (! method_exists($job, 'retryUntil') && ! isset($job->retryUntil)) { return; } - $expiration = $job->timeoutAt ?? $job->retryUntil(); + $expiration = $job->retryUntil ?? $job->retryUntil(); return $expiration instanceof DateTimeInterface ? $expiration->getTimestamp() : $expiration; } + /** + * Determine if the job should be encrypted. + * + * @param object $job + * @return bool + */ + protected function jobShouldBeEncrypted($job) + { + if ($job instanceof ShouldBeEncrypted) { + return true; + } + + return isset($job->shouldBeEncrypted) && $job->shouldBeEncrypted; + } + /** * Create a typical, string based queue payload array. * @@ -200,10 +244,13 @@ abstract class Queue protected function createStringPayload($job, $queue, $data) { return $this->withCreatePayloadHooks($queue, [ + 'uuid' => (string) Str::uuid(), 'displayName' => is_string($job) ? explode('@', $job)[0] : null, 'job' => $job, 'maxTries' => null, - 'delay' => null, + 'maxExceptions' => null, + 'failOnTimeout' => false, + 'backoff' => null, 'timeout' => null, 'data' => $data, ]); @@ -212,7 +259,7 @@ abstract class Queue /** * Register a callback to be executed when creating job payloads. * - * @param callable $callback + * @param callable|null $callback * @return void */ public static function createPayloadUsing($callback) @@ -244,6 +291,67 @@ abstract class Queue return $payload; } + /** + * Enqueue a job using the given callback. + * + * @param \Closure|string|object $job + * @param string $payload + * @param string $queue + * @param \DateTimeInterface|\DateInterval|int|null $delay + * @param callable $callback + * @return mixed + */ + protected function enqueueUsing($job, $payload, $queue, $delay, $callback) + { + if ($this->shouldDispatchAfterCommit($job) && + $this->container->bound('db.transactions')) { + return $this->container->make('db.transactions')->addCallback( + function () use ($payload, $queue, $delay, $callback, $job) { + return tap($callback($payload, $queue, $delay), function ($jobId) use ($job) { + $this->raiseJobQueuedEvent($jobId, $job); + }); + } + ); + } + + return tap($callback($payload, $queue, $delay), function ($jobId) use ($job) { + $this->raiseJobQueuedEvent($jobId, $job); + }); + } + + /** + * Determine if the job should be dispatched after all database transactions have committed. + * + * @param \Closure|string|object $job + * @return bool + */ + protected function shouldDispatchAfterCommit($job) + { + if (is_object($job) && isset($job->afterCommit)) { + return $job->afterCommit; + } + + if (isset($this->dispatchAfterCommit)) { + return $this->dispatchAfterCommit; + } + + return false; + } + + /** + * Raise the job queued event. + * + * @param string|int|null $jobId + * @param \Closure|string|object $job + * @return void + */ + protected function raiseJobQueuedEvent($jobId, $job) + { + if ($this->container->bound('events')) { + $this->container['events']->dispatch(new JobQueued($this->connectionName, $jobId, $job)); + } + } + /** * Get the connection name for the queue. * @@ -267,6 +375,16 @@ abstract class Queue return $this; } + /** + * Get the container instance being used by the connection. + * + * @return \Illuminate\Container\Container + */ + public function getContainer() + { + return $this->container; + } + /** * Set the IoC container instance. * diff --git a/src/Illuminate/Queue/QueueManager.php b/src/Illuminate/Queue/QueueManager.php index c13bee3253c1d400d54680ad1a93ca0ac18ecab6..33f1cd1652e9604029b63b37a154241105899f52 100755 --- a/src/Illuminate/Queue/QueueManager.php +++ b/src/Illuminate/Queue/QueueManager.php @@ -148,11 +148,17 @@ class QueueManager implements FactoryContract, MonitorContract * * @param string $name * @return \Illuminate\Contracts\Queue\Queue + * + * @throws \InvalidArgumentException */ protected function resolve($name) { $config = $this->getConfig($name); + if (is_null($config)) { + throw new InvalidArgumentException("The [{$name}] queue connection has not been configured."); + } + return $this->getConnector($config['driver']) ->connect($config) ->setConnectionName($name); @@ -169,7 +175,7 @@ class QueueManager implements FactoryContract, MonitorContract protected function getConnector($driver) { if (! isset($this->connectors[$driver])) { - throw new InvalidArgumentException("No connector for [$driver]"); + throw new InvalidArgumentException("No connector for [$driver]."); } return call_user_func($this->connectors[$driver]); @@ -203,7 +209,7 @@ class QueueManager implements FactoryContract, MonitorContract * Get the queue connection configuration. * * @param string $name - * @return array + * @return array|null */ protected function getConfig($name) { @@ -246,6 +252,33 @@ class QueueManager implements FactoryContract, MonitorContract return $connection ?: $this->getDefaultDriver(); } + /** + * Get the application instance used by the manager. + * + * @return \Illuminate\Contracts\Foundation\Application + */ + public function getApplication() + { + return $this->app; + } + + /** + * Set the application instance used by the manager. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return $this + */ + public function setApplication($app) + { + $this->app = $app; + + foreach ($this->connections as $connection) { + $connection->setContainer($app); + } + + return $this; + } + /** * Dynamically pass calls to the default connection. * diff --git a/src/Illuminate/Queue/QueueServiceProvider.php b/src/Illuminate/Queue/QueueServiceProvider.php index 0720923bda079920c9251b184e5d4bdc202bb534..97ac37efd345effc001e5539369862c584417bcc 100755 --- a/src/Illuminate/Queue/QueueServiceProvider.php +++ b/src/Illuminate/Queue/QueueServiceProvider.php @@ -12,15 +12,18 @@ use Illuminate\Queue\Connectors\RedisConnector; use Illuminate\Queue\Connectors\SqsConnector; use Illuminate\Queue\Connectors\SyncConnector; use Illuminate\Queue\Failed\DatabaseFailedJobProvider; +use Illuminate\Queue\Failed\DatabaseUuidFailedJobProvider; use Illuminate\Queue\Failed\DynamoDbFailedJobProvider; use Illuminate\Queue\Failed\NullFailedJobProvider; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Facade; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; -use Opis\Closure\SerializableClosure; +use Laravel\SerializableClosure\SerializableClosure; class QueueServiceProvider extends ServiceProvider implements DeferrableProvider { + use SerializesAndRestoresModelIdentifiers; + /** * Register the service provider. * @@ -28,12 +31,37 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider */ public function register() { + $this->configureSerializableClosureUses(); + $this->registerManager(); $this->registerConnection(); $this->registerWorker(); $this->registerListener(); $this->registerFailedJobServices(); - $this->registerOpisSecurityKey(); + } + + /** + * Configure serializable closures uses. + * + * @return void + */ + protected function configureSerializableClosureUses() + { + SerializableClosure::transformUseVariablesUsing(function ($data) { + foreach ($data as $key => $value) { + $data[$key] = $this->getSerializedPropertyValue($value); + } + + return $data; + }); + + SerializableClosure::resolveUseVariablesUsing(function ($data) { + foreach ($data as $key => $value) { + $data[$key] = $this->getRestoredPropertyValue($value); + } + + return $data; + }); } /** @@ -163,16 +191,27 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider */ protected function registerWorker() { - $this->app->singleton('queue.worker', function () { + $this->app->singleton('queue.worker', function ($app) { $isDownForMaintenance = function () { return $this->app->isDownForMaintenance(); }; + $resetScope = function () use ($app) { + if (method_exists($app['log']->driver(), 'withoutContext')) { + $app['log']->withoutContext(); + } + + $app->forgetScopedInstances(); + + return Facade::clearResolvedInstances(); + }; + return new Worker( - $this->app['queue'], - $this->app['events'], - $this->app[ExceptionHandler::class], - $isDownForMaintenance + $app['queue'], + $app['events'], + $app[ExceptionHandler::class], + $isDownForMaintenance, + $resetScope ); }); } @@ -184,8 +223,8 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider */ protected function registerListener() { - $this->app->singleton('queue.listener', function () { - return new Listener($this->app->basePath()); + $this->app->singleton('queue.listener', function ($app) { + return new Listener($app->basePath()); }); } @@ -196,11 +235,18 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider */ protected function registerFailedJobServices() { - $this->app->singleton('queue.failer', function () { - $config = $this->app['config']['queue.failed']; + $this->app->singleton('queue.failer', function ($app) { + $config = $app['config']['queue.failed']; + + if (array_key_exists('driver', $config) && + (is_null($config['driver']) || $config['driver'] === 'null')) { + return new NullFailedJobProvider; + } if (isset($config['driver']) && $config['driver'] === 'dynamodb') { return $this->dynamoFailedJobProvider($config); + } elseif (isset($config['driver']) && $config['driver'] === 'database-uuids') { + return $this->databaseUuidFailedJobProvider($config); } elseif (isset($config['table'])) { return $this->databaseFailedJobProvider($config); } else { @@ -222,6 +268,19 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider ); } + /** + * Create a new database failed job provider that uses UUIDs as IDs. + * + * @param array $config + * @return \Illuminate\Queue\Failed\DatabaseUuidFailedJobProvider + */ + protected function databaseUuidFailedJobProvider($config) + { + return new DatabaseUuidFailedJobProvider( + $this->app['db'], $config['database'], $config['table'] + ); + } + /** * Create a new DynamoDb failed job provider. * @@ -249,20 +308,6 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider ); } - /** - * Configure Opis Closure signing for security. - * - * @return void - */ - protected function registerOpisSecurityKey() - { - if (Str::startsWith($key = $this->app['config']->get('app.key'), 'base64:')) { - $key = base64_decode(substr($key, 7)); - } - - SerializableClosure::setSecretKey($key); - } - /** * Get the services provided by the provider. * @@ -271,8 +316,11 @@ class QueueServiceProvider extends ServiceProvider implements DeferrableProvider public function provides() { return [ - 'queue', 'queue.worker', 'queue.listener', - 'queue.failer', 'queue.connection', + 'queue', + 'queue.connection', + 'queue.failer', + 'queue.listener', + 'queue.worker', ]; } } diff --git a/src/Illuminate/Queue/RedisQueue.php b/src/Illuminate/Queue/RedisQueue.php index c5b38c1b9c6c50cd0d91ca8956e8b0038c98b51e..79efc0581f2434bc13f6aa6cfdc04c1b35223a39 100644 --- a/src/Illuminate/Queue/RedisQueue.php +++ b/src/Illuminate/Queue/RedisQueue.php @@ -2,12 +2,13 @@ namespace Illuminate\Queue; +use Illuminate\Contracts\Queue\ClearableQueue; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Queue\Jobs\RedisJob; use Illuminate\Support\Str; -class RedisQueue extends Queue implements QueueContract +class RedisQueue extends Queue implements QueueContract, ClearableQueue { /** * The Redis factory implementation. @@ -52,15 +53,22 @@ class RedisQueue extends Queue implements QueueContract * @param string|null $connection * @param int $retryAfter * @param int|null $blockFor + * @param bool $dispatchAfterCommit * @return void */ - public function __construct(Redis $redis, $default = 'default', $connection = null, $retryAfter = 60, $blockFor = null) + public function __construct(Redis $redis, + $default = 'default', + $connection = null, + $retryAfter = 60, + $blockFor = null, + $dispatchAfterCommit = false) { $this->redis = $redis; $this->default = $default; $this->blockFor = $blockFor; $this->connection = $connection; $this->retryAfter = $retryAfter; + $this->dispatchAfterCommit = $dispatchAfterCommit; } /** @@ -78,6 +86,25 @@ class RedisQueue extends Queue implements QueueContract ); } + /** + * Push an array of jobs onto the queue. + * + * @param array $jobs + * @param mixed $data + * @param string|null $queue + * @return void + */ + public function bulk($jobs, $data = '', $queue = null) + { + $this->getConnection()->pipeline(function () use ($jobs, $data, $queue) { + $this->getConnection()->transaction(function () use ($jobs, $data, $queue) { + foreach ((array) $jobs as $job) { + $this->push($job, $data, $queue); + } + }); + }); + } + /** * Push a new job onto the queue. * @@ -88,7 +115,15 @@ class RedisQueue extends Queue implements QueueContract */ public function push($job, $data = '', $queue = null) { - return $this->pushRaw($this->createPayload($job, $this->getQueue($queue), $data), $queue); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + null, + function ($payload, $queue) { + return $this->pushRaw($payload, $queue); + } + ); } /** @@ -120,7 +155,15 @@ class RedisQueue extends Queue implements QueueContract */ public function later($delay, $job, $data = '', $queue = null) { - return $this->laterRaw($delay, $this->createPayload($job, $this->getQueue($queue), $data), $queue); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + $delay, + function ($payload, $queue, $delay) { + return $this->laterRaw($delay, $payload, $queue); + } + ); } /** @@ -166,11 +209,7 @@ class RedisQueue extends Queue implements QueueContract { $this->migrate($prefixed = $this->getQueue($queue)); - if (empty($nextJob = $this->retrieveNextJob($prefixed))) { - return; - } - - [$job, $reserved] = $nextJob; + [$job, $reserved] = $this->retrieveNextJob($prefixed); if ($reserved) { return new RedisJob( @@ -267,6 +306,22 @@ class RedisQueue extends Queue implements QueueContract ); } + /** + * Delete all of the jobs from the queue. + * + * @param string $queue + * @return int + */ + public function clear($queue) + { + $queue = $this->getQueue($queue); + + return $this->getConnection()->eval( + LuaScripts::clear(), 4, $queue, $queue.':delayed', + $queue.':reserved', $queue.':notify' + ); + } + /** * Get a random ID string. * diff --git a/src/Illuminate/Queue/SerializableClosure.php b/src/Illuminate/Queue/SerializableClosure.php index f8a1cf4bc5eb9ac6006874c1f926147d48e939ef..cb0a587dc99d35a521d0b9529b609e13d053b547 100644 --- a/src/Illuminate/Queue/SerializableClosure.php +++ b/src/Illuminate/Queue/SerializableClosure.php @@ -4,6 +4,9 @@ namespace Illuminate\Queue; use Opis\Closure\SerializableClosure as OpisSerializableClosure; +/** + * @deprecated This class will be removed in Laravel 9. + */ class SerializableClosure extends OpisSerializableClosure { use SerializesAndRestoresModelIdentifiers; @@ -11,7 +14,7 @@ class SerializableClosure extends OpisSerializableClosure /** * Transform the use variables before serialization. * - * @param array $data The Closure's use variables + * @param array $data * @return array */ protected function transformUseVariables($data) @@ -26,7 +29,7 @@ class SerializableClosure extends OpisSerializableClosure /** * Resolve the use variables after unserialization. * - * @param array $data The Closure's transformed use variables + * @param array $data * @return array */ protected function resolveUseVariables($data) diff --git a/src/Illuminate/Queue/SerializableClosureFactory.php b/src/Illuminate/Queue/SerializableClosureFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..42aa3f6bf0b6bccff4d63e599497403d731b8dca --- /dev/null +++ b/src/Illuminate/Queue/SerializableClosureFactory.php @@ -0,0 +1,28 @@ +<?php + +namespace Illuminate\Queue; + +use Closure; +use Laravel\SerializableClosure\SerializableClosure; +use Opis\Closure\SerializableClosure as OpisSerializableClosure; + +/** + * @deprecated This class will be removed in Laravel 9. + */ +class SerializableClosureFactory +{ + /** + * Creates a new serializable closure from the given closure. + * + * @param \Closure $closure + * @return \Laravel\SerializableClosure\SerializableClosure + */ + public static function make($closure) + { + if (\PHP_VERSION_ID < 70400) { + return new OpisSerializableClosure($closure); + } + + return new SerializableClosure($closure); + } +} diff --git a/src/Illuminate/Queue/SerializesModels.php b/src/Illuminate/Queue/SerializesModels.php index 52c0f405d831b325c66a7ab06500339f4d999f0c..d60479d20708561fb861ca16fc690b7144c3bfce 100644 --- a/src/Illuminate/Queue/SerializesModels.php +++ b/src/Illuminate/Queue/SerializesModels.php @@ -91,7 +91,7 @@ trait SerializesModels * Restore the model after serialization. * * @param array $values - * @return array + * @return void */ public function __unserialize(array $values) { @@ -122,8 +122,6 @@ trait SerializesModels $this, $this->getRestoredPropertyValue($values[$name]) ); } - - return $values; } /** diff --git a/src/Illuminate/Queue/SqsQueue.php b/src/Illuminate/Queue/SqsQueue.php index badf5f98d6b2cbc71a75ed6218483c38c5ae5fd6..dab07b701a6aecec9aff2f2cea497ee67af16d9b 100755 --- a/src/Illuminate/Queue/SqsQueue.php +++ b/src/Illuminate/Queue/SqsQueue.php @@ -3,10 +3,12 @@ namespace Illuminate\Queue; use Aws\Sqs\SqsClient; +use Illuminate\Contracts\Queue\ClearableQueue; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Queue\Jobs\SqsJob; +use Illuminate\Support\Str; -class SqsQueue extends Queue implements QueueContract +class SqsQueue extends Queue implements QueueContract, ClearableQueue { /** * The Amazon SQS instance. @@ -29,19 +31,34 @@ class SqsQueue extends Queue implements QueueContract */ protected $prefix; + /** + * The queue name suffix. + * + * @var string + */ + private $suffix; + /** * Create a new Amazon SQS queue instance. * * @param \Aws\Sqs\SqsClient $sqs * @param string $default * @param string $prefix + * @param string $suffix + * @param bool $dispatchAfterCommit * @return void */ - public function __construct(SqsClient $sqs, $default, $prefix = '') + public function __construct(SqsClient $sqs, + $default, + $prefix = '', + $suffix = '', + $dispatchAfterCommit = false) { $this->sqs = $sqs; $this->prefix = $prefix; $this->default = $default; + $this->suffix = $suffix; + $this->dispatchAfterCommit = $dispatchAfterCommit; } /** @@ -72,7 +89,15 @@ class SqsQueue extends Queue implements QueueContract */ public function push($job, $data = '', $queue = null) { - return $this->pushRaw($this->createPayload($job, $queue ?: $this->default, $data), $queue); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $queue ?: $this->default, $data), + $queue, + null, + function ($payload, $queue) { + return $this->pushRaw($payload, $queue); + } + ); } /** @@ -101,11 +126,19 @@ class SqsQueue extends Queue implements QueueContract */ public function later($delay, $job, $data = '', $queue = null) { - return $this->sqs->sendMessage([ - 'QueueUrl' => $this->getQueue($queue), - 'MessageBody' => $this->createPayload($job, $queue ?: $this->default, $data), - 'DelaySeconds' => $this->secondsUntil($delay), - ])->get('MessageId'); + return $this->enqueueUsing( + $job, + $this->createPayload($job, $queue ?: $this->default, $data), + $queue, + $delay, + function ($payload, $queue, $delay) { + return $this->sqs->sendMessage([ + 'QueueUrl' => $this->getQueue($queue), + 'MessageBody' => $payload, + 'DelaySeconds' => $this->secondsUntil($delay), + ])->get('MessageId'); + } + ); } /** @@ -129,6 +162,21 @@ class SqsQueue extends Queue implements QueueContract } } + /** + * Delete all of the jobs from the queue. + * + * @param string $queue + * @return int + */ + public function clear($queue) + { + return tap($this->size($queue), function () use ($queue) { + $this->sqs->purgeQueue([ + 'QueueUrl' => $this->getQueue($queue), + ]); + }); + } + /** * Get the queue or return the default. * @@ -140,7 +188,26 @@ class SqsQueue extends Queue implements QueueContract $queue = $queue ?: $this->default; return filter_var($queue, FILTER_VALIDATE_URL) === false - ? rtrim($this->prefix, '/').'/'.$queue : $queue; + ? $this->suffixQueue($queue, $this->suffix) + : $queue; + } + + /** + * Add the given suffix to the given queue name. + * + * @param string $queue + * @param string $suffix + * @return string + */ + protected function suffixQueue($queue, $suffix = '') + { + if (Str::endsWith($queue, '.fifo')) { + $queue = Str::beforeLast($queue, '.fifo'); + + return rtrim($this->prefix, '/').'/'.Str::finish($queue, $suffix).'.fifo'; + } + + return rtrim($this->prefix, '/').'/'.Str::finish($queue, $this->suffix); } /** diff --git a/src/Illuminate/Queue/SyncQueue.php b/src/Illuminate/Queue/SyncQueue.php index 812f7b3e849cbad44c2c1cb5a6a4fffbc1042939..33638c213a668aef0b0b17e307389f2dd552c993 100755 --- a/src/Illuminate/Queue/SyncQueue.php +++ b/src/Illuminate/Queue/SyncQueue.php @@ -2,14 +2,12 @@ namespace Illuminate\Queue; -use Exception; use Illuminate\Contracts\Queue\Job; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Jobs\SyncJob; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Throwable; class SyncQueue extends Queue implements QueueContract @@ -33,7 +31,7 @@ class SyncQueue extends Queue implements QueueContract * @param string|null $queue * @return mixed * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function push($job, $data = '', $queue = null) { @@ -45,10 +43,8 @@ class SyncQueue extends Queue implements QueueContract $queueJob->fire(); $this->raiseAfterJobEvent($queueJob); - } catch (Exception $e) { - $this->handleException($queueJob, $e); } catch (Throwable $e) { - $this->handleException($queueJob, new FatalThrowableError($e)); + $this->handleException($queueJob, $e); } return 0; @@ -96,10 +92,10 @@ class SyncQueue extends Queue implements QueueContract * Raise the exception occurred queue job event. * * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function raiseExceptionOccurredJobEvent(Job $job, $e) + protected function raiseExceptionOccurredJobEvent(Job $job, Throwable $e) { if ($this->container->bound('events')) { $this->container['events']->dispatch(new JobExceptionOccurred($this->connectionName, $job, $e)); @@ -109,13 +105,13 @@ class SyncQueue extends Queue implements QueueContract /** * Handle an exception that occurred while processing a job. * - * @param \Illuminate\Queue\Jobs\Job $queueJob - * @param \Exception $e + * @param \Illuminate\Contracts\Queue\Job $queueJob + * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleException($queueJob, $e) + protected function handleException(Job $queueJob, Throwable $e) { $this->raiseExceptionOccurredJobEvent($queueJob, $e); diff --git a/src/Illuminate/Queue/Worker.php b/src/Illuminate/Queue/Worker.php index 80d17f16975ad09bc9a568e060be539eaea0c08e..a46a6f7988006fa85f37b2247857d552d3ffd376 100644 --- a/src/Illuminate/Queue/Worker.php +++ b/src/Illuminate/Queue/Worker.php @@ -2,7 +2,6 @@ namespace Illuminate\Queue; -use Exception; use Illuminate\Contracts\Cache\Repository as CacheContract; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; @@ -14,13 +13,23 @@ use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Events\Looping; use Illuminate\Queue\Events\WorkerStopping; use Illuminate\Support\Carbon; -use Symfony\Component\Debug\Exception\FatalThrowableError; use Throwable; class Worker { use DetectsLostConnections; + const EXIT_SUCCESS = 0; + const EXIT_ERROR = 1; + const EXIT_MEMORY_LIMIT = 12; + + /** + * The name of the worker. + * + * @var string + */ + protected $name; + /** * The queue manager instance. * @@ -56,6 +65,13 @@ class Worker */ protected $isDownForMaintenance; + /** + * The callback used to reset the application's scope. + * + * @var callable + */ + protected $resetScope; + /** * Indicates if the worker should exit. * @@ -70,6 +86,13 @@ class Worker */ public $paused = false; + /** + * The callbacks used to pop jobs from queues. + * + * @var callable[] + */ + protected static $popCallbacks = []; + /** * Create a new queue worker. * @@ -77,17 +100,20 @@ class Worker * @param \Illuminate\Contracts\Events\Dispatcher $events * @param \Illuminate\Contracts\Debug\ExceptionHandler $exceptions * @param callable $isDownForMaintenance + * @param callable|null $resetScope * @return void */ public function __construct(QueueManager $manager, Dispatcher $events, ExceptionHandler $exceptions, - callable $isDownForMaintenance) + callable $isDownForMaintenance, + callable $resetScope = null) { $this->events = $events; $this->manager = $manager; $this->exceptions = $exceptions; $this->isDownForMaintenance = $isDownForMaintenance; + $this->resetScope = $resetScope; } /** @@ -96,26 +122,36 @@ class Worker * @param string $connectionName * @param string $queue * @param \Illuminate\Queue\WorkerOptions $options - * @return void + * @return int */ public function daemon($connectionName, $queue, WorkerOptions $options) { - if ($this->supportsAsyncSignals()) { + if ($supportsAsyncSignals = $this->supportsAsyncSignals()) { $this->listenForSignals(); } $lastRestart = $this->getTimestampOfLastQueueRestart(); + [$startTime, $jobsProcessed] = [hrtime(true) / 1e9, 0]; + while (true) { // Before reserving any jobs, we will make sure this queue is not paused and // if it is we will just pause this worker for a given amount of time and // make sure we do not need to kill this worker process off completely. if (! $this->daemonShouldRun($options, $connectionName, $queue)) { - $this->pauseWorker($options, $lastRestart); + $status = $this->pauseWorker($options, $lastRestart); + + if (! is_null($status)) { + return $this->stop($status); + } continue; } + if (isset($this->resetScope)) { + ($this->resetScope)(); + } + // First, we will attempt to get the next job off of the queue. We will also // register the timeout handler and reset the alarm for this job so it is // not stuck in a frozen state forever. Then, we can fire off this job. @@ -123,7 +159,7 @@ class Worker $this->manager->connection($connectionName), $queue ); - if ($this->supportsAsyncSignals()) { + if ($supportsAsyncSignals) { $this->registerTimeoutHandler($job, $options); } @@ -131,19 +167,31 @@ class Worker // fire off this job for processing. Otherwise, we will need to sleep the // worker so no more jobs are processed until they should be processed. if ($job) { + $jobsProcessed++; + $this->runJob($job, $connectionName, $options); + + if ($options->rest > 0) { + $this->sleep($options->rest); + } } else { $this->sleep($options->sleep); } - if ($this->supportsAsyncSignals()) { + if ($supportsAsyncSignals) { $this->resetTimeoutHandler(); } // Finally, we will check to see if we have exceeded our memory limits or if // the queue should restart based on other indications. If so, we'll stop // this worker and let whatever is "monitoring" it restart the process. - $this->stopIfNecessary($options, $lastRestart, $job); + $status = $this->stopIfNecessary( + $options, $lastRestart, $startTime, $jobsProcessed, $job + ); + + if (! is_null($status)) { + return $this->stop($status); + } } } @@ -162,11 +210,19 @@ class Worker pcntl_signal(SIGALRM, function () use ($job, $options) { if ($job) { $this->markJobAsFailedIfWillExceedMaxAttempts( - $job->getConnectionName(), $job, (int) $options->maxTries, $this->maxAttemptsExceededException($job) + $job->getConnectionName(), $job, (int) $options->maxTries, $e = $this->maxAttemptsExceededException($job) + ); + + $this->markJobAsFailedIfWillExceedMaxExceptions( + $job->getConnectionName(), $job, $e + ); + + $this->markJobAsFailedIfItShouldFailOnTimeout( + $job->getConnectionName(), $job, $e ); } - $this->kill(1); + $this->kill(static::EXIT_ERROR); }); pcntl_alarm( @@ -216,33 +272,39 @@ class Worker * * @param \Illuminate\Queue\WorkerOptions $options * @param int $lastRestart - * @return void + * @return int|null */ protected function pauseWorker(WorkerOptions $options, $lastRestart) { $this->sleep($options->sleep > 0 ? $options->sleep : 1); - $this->stopIfNecessary($options, $lastRestart); + return $this->stopIfNecessary($options, $lastRestart); } /** - * Stop the process if necessary. + * Determine the exit code to stop the process if necessary. * * @param \Illuminate\Queue\WorkerOptions $options * @param int $lastRestart + * @param int $startTime + * @param int $jobsProcessed * @param mixed $job - * @return void + * @return int|null */ - protected function stopIfNecessary(WorkerOptions $options, $lastRestart, $job = null) + protected function stopIfNecessary(WorkerOptions $options, $lastRestart, $startTime = 0, $jobsProcessed = 0, $job = null) { if ($this->shouldQuit) { - $this->stop(); + return static::EXIT_SUCCESS; } elseif ($this->memoryExceeded($options->memory)) { - $this->stop(12); + return static::EXIT_MEMORY_LIMIT; } elseif ($this->queueShouldRestart($lastRestart)) { - $this->stop(); + return static::EXIT_SUCCESS; } elseif ($options->stopWhenEmpty && is_null($job)) { - $this->stop(); + return static::EXIT_SUCCESS; + } elseif ($options->maxTime && hrtime(true) / 1e9 - $startTime >= $options->maxTime) { + return static::EXIT_SUCCESS; + } elseif ($options->maxJobs && $jobsProcessed >= $options->maxJobs) { + return static::EXIT_SUCCESS; } } @@ -279,20 +341,22 @@ class Worker */ protected function getNextJob($connection, $queue) { + $popJobCallback = function ($queue) use ($connection) { + return $connection->pop($queue); + }; + try { + if (isset(static::$popCallbacks[$this->name])) { + return (static::$popCallbacks[$this->name])($popJobCallback, $queue); + } + foreach (explode(',', $queue) as $queue) { - if (! is_null($job = $connection->pop($queue))) { + if (! is_null($job = $popJobCallback($queue))) { return $job; } } - } catch (Exception $e) { - $this->exceptions->report($e); - - $this->stopWorkerIfLostConnection($e); - - $this->sleep(1); } catch (Throwable $e) { - $this->exceptions->report($e = new FatalThrowableError($e)); + $this->exceptions->report($e); $this->stopWorkerIfLostConnection($e); @@ -312,12 +376,8 @@ class Worker { try { return $this->process($connectionName, $job, $options); - } catch (Exception $e) { - $this->exceptions->report($e); - - $this->stopWorkerIfLostConnection($e); } catch (Throwable $e) { - $this->exceptions->report($e = new FatalThrowableError($e)); + $this->exceptions->report($e); $this->stopWorkerIfLostConnection($e); } @@ -368,12 +428,8 @@ class Worker $job->fire(); $this->raiseAfterJobEvent($connectionName, $job); - } catch (Exception $e) { - $this->handleJobException($connectionName, $job, $options, $e); } catch (Throwable $e) { - $this->handleJobException( - $connectionName, $job, $options, new FatalThrowableError($e) - ); + $this->handleJobException($connectionName, $job, $options, $e); } } @@ -383,12 +439,12 @@ class Worker * @param string $connectionName * @param \Illuminate\Contracts\Queue\Job $job * @param \Illuminate\Queue\WorkerOptions $options - * @param \Exception $e + * @param \Throwable $e * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleJobException($connectionName, $job, WorkerOptions $options, $e) + protected function handleJobException($connectionName, $job, WorkerOptions $options, Throwable $e) { try { // First, we will go ahead and mark the job as failed if it will exceed the maximum @@ -398,6 +454,10 @@ class Worker $this->markJobAsFailedIfWillExceedMaxAttempts( $connectionName, $job, (int) $options->maxTries, $e ); + + $this->markJobAsFailedIfWillExceedMaxExceptions( + $connectionName, $job, $e + ); } $this->raiseExceptionOccurredJobEvent( @@ -408,11 +468,7 @@ class Worker // so it is not lost entirely. This'll let the job be retried at a later time by // another listener (or this same one). We will re-throw this exception after. if (! $job->isDeleted() && ! $job->isReleased() && ! $job->hasFailed()) { - $job->release( - method_exists($job, 'delaySeconds') && ! is_null($job->delaySeconds()) - ? $job->delaySeconds() - : $options->delay - ); + $job->release($this->calculateBackoff($job, $options)); } } @@ -428,18 +484,20 @@ class Worker * @param \Illuminate\Contracts\Queue\Job $job * @param int $maxTries * @return void + * + * @throws \Throwable */ protected function markJobAsFailedIfAlreadyExceedsMaxAttempts($connectionName, $job, $maxTries) { $maxTries = ! is_null($job->maxTries()) ? $job->maxTries() : $maxTries; - $timeoutAt = $job->timeoutAt(); + $retryUntil = $job->retryUntil(); - if ($timeoutAt && Carbon::now()->getTimestamp() <= $timeoutAt) { + if ($retryUntil && Carbon::now()->getTimestamp() <= $retryUntil) { return; } - if (! $timeoutAt && ($maxTries === 0 || $job->attempts() <= $maxTries)) { + if (! $retryUntil && ($maxTries === 0 || $job->attempts() <= $maxTries)) { return; } @@ -454,18 +512,59 @@ class Worker * @param string $connectionName * @param \Illuminate\Contracts\Queue\Job $job * @param int $maxTries - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function markJobAsFailedIfWillExceedMaxAttempts($connectionName, $job, $maxTries, $e) + protected function markJobAsFailedIfWillExceedMaxAttempts($connectionName, $job, $maxTries, Throwable $e) { $maxTries = ! is_null($job->maxTries()) ? $job->maxTries() : $maxTries; - if ($job->timeoutAt() && $job->timeoutAt() <= Carbon::now()->getTimestamp()) { + if ($job->retryUntil() && $job->retryUntil() <= Carbon::now()->getTimestamp()) { $this->failJob($job, $e); } - if ($maxTries > 0 && $job->attempts() >= $maxTries) { + if (! $job->retryUntil() && $maxTries > 0 && $job->attempts() >= $maxTries) { + $this->failJob($job, $e); + } + } + + /** + * Mark the given job as failed if it has exceeded the maximum allowed attempts. + * + * @param string $connectionName + * @param \Illuminate\Contracts\Queue\Job $job + * @param \Throwable $e + * @return void + */ + protected function markJobAsFailedIfWillExceedMaxExceptions($connectionName, $job, Throwable $e) + { + if (! $this->cache || is_null($uuid = $job->uuid()) || + is_null($maxExceptions = $job->maxExceptions())) { + return; + } + + if (! $this->cache->get('job-exceptions:'.$uuid)) { + $this->cache->put('job-exceptions:'.$uuid, 0, Carbon::now()->addDay()); + } + + if ($maxExceptions <= $this->cache->increment('job-exceptions:'.$uuid)) { + $this->cache->forget('job-exceptions:'.$uuid); + + $this->failJob($job, $e); + } + } + + /** + * Mark the given job as failed if it should fail on timeouts. + * + * @param string $connectionName + * @param \Illuminate\Contracts\Queue\Job $job + * @param \Throwable $e + * @return void + */ + protected function markJobAsFailedIfItShouldFailOnTimeout($connectionName, $job, Throwable $e) + { + if (method_exists($job, 'shouldFailOnTimeout') ? $job->shouldFailOnTimeout() : false) { $this->failJob($job, $e); } } @@ -474,14 +573,33 @@ class Worker * Mark the given job as failed and raise the relevant event. * * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function failJob($job, $e) + protected function failJob($job, Throwable $e) { return $job->fail($e); } + /** + * Calculate the backoff for the given job. + * + * @param \Illuminate\Contracts\Queue\Job $job + * @param \Illuminate\Queue\WorkerOptions $options + * @return int + */ + protected function calculateBackoff($job, WorkerOptions $options) + { + $backoff = explode( + ',', + method_exists($job, 'backoff') && ! is_null($job->backoff()) + ? $job->backoff() + : $options->backoff + ); + + return (int) ($backoff[$job->attempts() - 1] ?? last($backoff)); + } + /** * Raise the before queue job event. * @@ -515,10 +633,10 @@ class Worker * * @param string $connectionName * @param \Illuminate\Contracts\Queue\Job $job - * @param \Exception $e + * @param \Throwable $e * @return void */ - protected function raiseExceptionOccurredJobEvent($connectionName, $job, $e) + protected function raiseExceptionOccurredJobEvent($connectionName, $job, Throwable $e) { $this->events->dispatch(new JobExceptionOccurred( $connectionName, $job, $e @@ -595,20 +713,20 @@ class Worker * Stop listening and bail out of the script. * * @param int $status - * @return void + * @return int */ public function stop($status = 0) { $this->events->dispatch(new WorkerStopping($status)); - exit($status); + return $status; } /** * Kill the process. * * @param int $status - * @return void + * @return never */ public function kill($status = 0) { @@ -624,7 +742,7 @@ class Worker /** * Create an instance of MaxAttemptsExceededException. * - * @param \Illuminate\Contracts\Queue\Job|null $job + * @param \Illuminate\Contracts\Queue\Job $job * @return \Illuminate\Queue\MaxAttemptsExceededException */ protected function maxAttemptsExceededException($job) @@ -653,11 +771,42 @@ class Worker * Set the cache repository implementation. * * @param \Illuminate\Contracts\Cache\Repository $cache - * @return void + * @return $this */ public function setCache(CacheContract $cache) { $this->cache = $cache; + + return $this; + } + + /** + * Set the name of the worker. + * + * @param string $name + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Register a callback to be executed to pick jobs. + * + * @param string $workerName + * @param callable $callback + * @return void + */ + public static function popUsing($workerName, $callback) + { + if (is_null($callback)) { + unset(static::$popCallbacks[$workerName]); + } else { + static::$popCallbacks[$workerName] = $callback; + } } /** diff --git a/src/Illuminate/Queue/WorkerOptions.php b/src/Illuminate/Queue/WorkerOptions.php index f4cb1f64c533a69348ea76fe597ba84b549457c9..7b8d8dfeea3b4364ca0e8981ed4c922a8c3926f8 100644 --- a/src/Illuminate/Queue/WorkerOptions.php +++ b/src/Illuminate/Queue/WorkerOptions.php @@ -5,11 +5,18 @@ namespace Illuminate\Queue; class WorkerOptions { /** - * The number of seconds before a released job will be available. + * The name of the worker. * * @var int */ - public $delay; + public $name; + + /** + * The number of seconds to wait before retrying a job that encountered an uncaught exception. + * + * @var int + */ + public $backoff; /** * The maximum amount of RAM the worker may consume. @@ -32,6 +39,13 @@ class WorkerOptions */ public $sleep; + /** + * The number of seconds to rest between jobs. + * + * @var int + */ + public $rest; + /** * The maximum amount of times a job may be attempted. * @@ -47,32 +61,55 @@ class WorkerOptions public $force; /** - * Indicates if the worker should stop when queue is empty. + * Indicates if the worker should stop when the queue is empty. * * @var bool */ public $stopWhenEmpty; + /** + * The maximum number of jobs to run. + * + * @var int + */ + public $maxJobs; + + /** + * The maximum number of seconds a worker may live. + * + * @var int + */ + public $maxTime; + /** * Create a new worker options instance. * - * @param int $delay + * @param string $name + * @param int $backoff * @param int $memory * @param int $timeout * @param int $sleep * @param int $maxTries * @param bool $force * @param bool $stopWhenEmpty + * @param int $maxJobs + * @param int $maxTime + * @param int $rest * @return void */ - public function __construct($delay = 0, $memory = 128, $timeout = 60, $sleep = 3, $maxTries = 1, $force = false, $stopWhenEmpty = false) + public function __construct($name = 'default', $backoff = 0, $memory = 128, $timeout = 60, $sleep = 3, $maxTries = 1, + $force = false, $stopWhenEmpty = false, $maxJobs = 0, $maxTime = 0, $rest = 0) { - $this->delay = $delay; + $this->name = $name; + $this->backoff = $backoff; $this->sleep = $sleep; + $this->rest = $rest; $this->force = $force; $this->memory = $memory; $this->timeout = $timeout; $this->maxTries = $maxTries; $this->stopWhenEmpty = $stopWhenEmpty; + $this->maxJobs = $maxJobs; + $this->maxTime = $maxTime; } } diff --git a/src/Illuminate/Queue/composer.json b/src/Illuminate/Queue/composer.json index 8e1616012c48ab1dff37b1be4865c5fe4eae99fc..6c43b180e4f67ae3e55d271ae0981812d44b05a6 100644 --- a/src/Illuminate/Queue/composer.json +++ b/src/Illuminate/Queue/composer.json @@ -14,18 +14,20 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/console": "^6.0", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/database": "^6.0", - "illuminate/filesystem": "^6.0", - "illuminate/pipeline": "^6.0", - "illuminate/support": "^6.0", + "illuminate/collections": "^8.0", + "illuminate/console": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/database": "^8.0", + "illuminate/filesystem": "^8.0", + "illuminate/pipeline": "^8.0", + "illuminate/support": "^8.0", + "laravel/serializable-closure": "^1.0", "opis/closure": "^3.6", - "symfony/debug": "^4.3.4", - "symfony/process": "^4.3.4" + "ramsey/uuid": "^4.2.2", + "symfony/process": "^5.4" }, "autoload": { "psr-4": { @@ -34,14 +36,14 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { "ext-pcntl": "Required to use all features of the queue worker.", "ext-posix": "Required to use all features of the queue worker.", - "aws/aws-sdk-php": "Required to use the SQS queue driver and DynamoDb failed job storage (^3.155).", - "illuminate/redis": "Required to use the Redis queue driver (^6.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver and DynamoDb failed job storage (^3.198.1).", + "illuminate/redis": "Required to use the Redis queue driver (^8.0).", "pda/pheanstalk": "Required to use the Beanstalk queue driver (^4.0)." }, "config": { diff --git a/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php b/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php new file mode 100644 index 0000000000000000000000000000000000000000..4d27ff59aebd2225b63760d9e6a50f4e181a4316 --- /dev/null +++ b/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php @@ -0,0 +1,183 @@ +<?php + +namespace Illuminate\Redis\Connections; + +use Redis; +use RuntimeException; +use UnexpectedValueException; + +trait PacksPhpRedisValues +{ + /** + * Indicates if Redis supports packing. + * + * @var bool|null + */ + protected $supportsPacking; + + /** + * Indicates if Redis supports LZF compression. + * + * @var bool|null + */ + protected $supportsLzf; + + /** + * Indicates if Redis supports Zstd compression. + * + * @var bool|null + */ + protected $supportsZstd; + + /** + * Prepares the given values to be used with the `eval` command, including serialization and compression. + * + * @param array<int|string,string> $values + * @return array<int|string,string> + */ + public function pack(array $values): array + { + if (empty($values)) { + return $values; + } + + if ($this->supportsPacking()) { + return array_map([$this->client, '_pack'], $values); + } + + if ($this->compressed()) { + if ($this->supportsLzf() && $this->lzfCompressed()) { + if (! function_exists('lzf_compress')) { + throw new RuntimeException("'lzf' extension required to call 'lzf_compress'."); + } + + $processor = function ($value) { + return \lzf_compress($this->client->_serialize($value)); + }; + } elseif ($this->supportsZstd() && $this->zstdCompressed()) { + if (! function_exists('zstd_compress')) { + throw new RuntimeException("'zstd' extension required to call 'zstd_compress'."); + } + + $compressionLevel = $this->client->getOption(Redis::OPT_COMPRESSION_LEVEL); + + $processor = function ($value) use ($compressionLevel) { + return \zstd_compress( + $this->client->_serialize($value), + $compressionLevel === 0 ? Redis::COMPRESSION_ZSTD_DEFAULT : $compressionLevel + ); + }; + } else { + throw new UnexpectedValueException(sprintf( + 'Unsupported phpredis compression in use [%d].', + $this->client->getOption(Redis::OPT_COMPRESSION) + )); + } + } else { + $processor = function ($value) { + return $this->client->_serialize($value); + }; + } + + return array_map($processor, $values); + } + + /** + * Determine if compression is enabled. + * + * @return bool + */ + public function compressed(): bool + { + return defined('Redis::OPT_COMPRESSION') && + $this->client->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; + } + + /** + * Determine if LZF compression is enabled. + * + * @return bool + */ + public function lzfCompressed(): bool + { + return defined('Redis::COMPRESSION_LZF') && + $this->client->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_LZF; + } + + /** + * Determine if ZSTD compression is enabled. + * + * @return bool + */ + public function zstdCompressed(): bool + { + return defined('Redis::COMPRESSION_ZSTD') && + $this->client->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_ZSTD; + } + + /** + * Determine if LZ4 compression is enabled. + * + * @return bool + */ + public function lz4Compressed(): bool + { + return defined('Redis::COMPRESSION_LZ4') && + $this->client->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_LZ4; + } + + /** + * Determine if the current PhpRedis extension version supports packing. + * + * @return bool + */ + protected function supportsPacking(): bool + { + if ($this->supportsPacking === null) { + $this->supportsPacking = $this->phpRedisVersionAtLeast('5.3.5'); + } + + return $this->supportsPacking; + } + + /** + * Determine if the current PhpRedis extension version supports LZF compression. + * + * @return bool + */ + protected function supportsLzf(): bool + { + if ($this->supportsLzf === null) { + $this->supportsLzf = $this->phpRedisVersionAtLeast('4.3.0'); + } + + return $this->supportsLzf; + } + + /** + * Determine if the current PhpRedis extension version supports Zstd compression. + * + * @return bool + */ + protected function supportsZstd(): bool + { + if ($this->supportsZstd === null) { + $this->supportsZstd = $this->phpRedisVersionAtLeast('5.1.0'); + } + + return $this->supportsZstd; + } + + /** + * Determine if the PhpRedis extension version is at least the given version. + * + * @param string $version + * @return bool + */ + protected function phpRedisVersionAtLeast(string $version): bool + { + $phpredisVersion = phpversion('redis'); + + return $phpredisVersion !== false && version_compare($phpredisVersion, $version, '>='); + } +} diff --git a/src/Illuminate/Redis/Connections/PhpRedisClusterConnection.php b/src/Illuminate/Redis/Connections/PhpRedisClusterConnection.php index e246fe6a1d12e45a3438f26ad1b95405824d5e57..bf4816a4306e1894a217afb3b72900d6d6b74100 100644 --- a/src/Illuminate/Redis/Connections/PhpRedisClusterConnection.php +++ b/src/Illuminate/Redis/Connections/PhpRedisClusterConnection.php @@ -4,5 +4,21 @@ namespace Illuminate\Redis\Connections; class PhpRedisClusterConnection extends PhpRedisConnection { - // + /** + * Flush the selected Redis database on all master nodes. + * + * @return mixed + */ + public function flushdb() + { + $arguments = func_get_args(); + + $async = strtoupper((string) ($arguments[0] ?? null)) === 'ASYNC'; + + foreach ($this->client->_masters() as $master) { + $async + ? $this->command('rawCommand', [$master, 'flushdb', 'async']) + : $this->command('flushdb', [$master]); + } + } } diff --git a/src/Illuminate/Redis/Connections/PhpRedisConnection.php b/src/Illuminate/Redis/Connections/PhpRedisConnection.php index 0950ec97cdcbe15940915ece5637debea806837d..4e68547de3d0137f8b434becf5f011e0ddd7d02b 100644 --- a/src/Illuminate/Redis/Connections/PhpRedisConnection.php +++ b/src/Illuminate/Redis/Connections/PhpRedisConnection.php @@ -4,9 +4,9 @@ namespace Illuminate\Redis\Connections; use Closure; use Illuminate\Contracts\Redis\Connection as ConnectionContract; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Redis; -use RedisCluster; use RedisException; /** @@ -14,6 +14,8 @@ use RedisException; */ class PhpRedisConnection extends Connection implements ConnectionContract { + use PacksPhpRedisValues; + /** * The connection creation callback. * @@ -70,7 +72,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract } /** - * Set the string value in argument as value of the key. + * Set the string value in the argument as the value of the key. * * @param string $key * @param mixed $value @@ -197,7 +199,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract */ public function spop($key, $count = 1) { - return $this->command('spop', [$key, $count]); + return $this->command('spop', func_get_args()); } /** @@ -219,7 +221,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract $options = []; foreach (array_slice($dictionary, 0, 3) as $i => $value) { - if (in_array($value, ['nx', 'xx', 'ch', 'incr', 'NX', 'XX', 'CH', 'INCR'], true)) { + if (in_array($value, ['nx', 'xx', 'ch', 'incr', 'gt', 'lt', 'NX', 'XX', 'CH', 'INCR', 'GT', 'LT'], true)) { $options[] = $value; unset($dictionary[$i]); @@ -240,7 +242,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract */ public function zrangebyscore($key, $min, $max, $options = []) { - if (isset($options['limit'])) { + if (isset($options['limit']) && Arr::isAssoc($options['limit'])) { $options['limit'] = [ $options['limit']['offset'], $options['limit']['count'], @@ -261,7 +263,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract */ public function zrevrangebyscore($key, $min, $max, $options = []) { - if (isset($options['limit'])) { + if (isset($options['limit']) && Arr::isAssoc($options['limit'])) { $options['limit'] = [ $options['limit']['offset'], $options['limit']['count'], @@ -304,10 +306,10 @@ class PhpRedisConnection extends Connection implements ConnectionContract } /** - * Scans the all keys based on options. + * Scans all keys based on options. * - * @param mixed $cursor - * @param array $options + * @param mixed $cursor + * @param array $options * @return mixed */ public function scan($cursor, $options = []) @@ -317,15 +319,19 @@ class PhpRedisConnection extends Connection implements ConnectionContract $options['count'] ?? 10 ); - return empty($result) ? $result : [$cursor, $result]; + if ($result === false) { + $result = []; + } + + return $cursor === 0 && empty($result) ? false : [$cursor, $result]; } /** * Scans the given set for all values based on options. * - * @param string $key - * @param mixed $cursor - * @param array $options + * @param string $key + * @param mixed $cursor + * @param array $options * @return mixed */ public function zscan($key, $cursor, $options = []) @@ -335,15 +341,19 @@ class PhpRedisConnection extends Connection implements ConnectionContract $options['count'] ?? 10 ); - return $result === false ? [0, []] : [$cursor, $result]; + if ($result === false) { + $result = []; + } + + return $cursor === 0 && empty($result) ? false : [$cursor, $result]; } /** - * Scans the given set for all values based on options. + * Scans the given hash for all values based on options. * - * @param string $key - * @param mixed $cursor - * @param array $options + * @param string $key + * @param mixed $cursor + * @param array $options * @return mixed */ public function hscan($key, $cursor, $options = []) @@ -353,15 +363,19 @@ class PhpRedisConnection extends Connection implements ConnectionContract $options['count'] ?? 10 ); - return $result === false ? [0, []] : [$cursor, $result]; + if ($result === false) { + $result = []; + } + + return $cursor === 0 && empty($result) ? false : [$cursor, $result]; } /** * Scans the given set for all values based on options. * - * @param string $key - * @param mixed $cursor - * @param array $options + * @param string $key + * @param mixed $cursor + * @param array $options * @return mixed */ public function sscan($key, $cursor, $options = []) @@ -371,7 +385,11 @@ class PhpRedisConnection extends Connection implements ConnectionContract $options['count'] ?? 10 ); - return $result === false ? [0, []] : [$cursor, $result]; + if ($result === false) { + $result = []; + } + + return $cursor === 0 && empty($result) ? false : [$cursor, $result]; } /** @@ -476,23 +494,17 @@ class PhpRedisConnection extends Connection implements ConnectionContract /** * Flush the selected Redis database. * - * @return void + * @return mixed */ public function flushdb() { - if (! $this->client instanceof RedisCluster) { - return $this->command('flushdb'); - } - - foreach ($this->client->_masters() as [$host, $port]) { - $redis = tap(new Redis)->connect($host, $port); - - if (isset($this->config['password']) && ! empty($this->config['password'])) { - $redis->auth($this->config['password']); - } + $arguments = func_get_args(); - $redis->flushDb(); + if (strtoupper((string) ($arguments[0] ?? null)) === 'ASYNC') { + return $this->command('flushdb', [true]); } + + return $this->command('flushdb'); } /** @@ -512,6 +524,8 @@ class PhpRedisConnection extends Connection implements ConnectionContract * @param string $method * @param array $parameters * @return mixed + * + * @throws \RedisException */ public function command($method, array $parameters = []) { @@ -537,7 +551,7 @@ class PhpRedisConnection extends Connection implements ConnectionContract } /** - * Apply prefix to the given key if necessary. + * Apply a prefix to the given key if necessary. * * @param string $key * @return string diff --git a/src/Illuminate/Redis/Connections/PredisClusterConnection.php b/src/Illuminate/Redis/Connections/PredisClusterConnection.php index 399be1ea73aa6bb55dc1c60b4ced6165ba5298fb..6d07de16191d55889013225632ddc942e84779ba 100644 --- a/src/Illuminate/Redis/Connections/PredisClusterConnection.php +++ b/src/Illuminate/Redis/Connections/PredisClusterConnection.php @@ -2,7 +2,19 @@ namespace Illuminate\Redis\Connections; +use Predis\Command\ServerFlushDatabase; + class PredisClusterConnection extends PredisConnection { - // + /** + * Flush the selected Redis database on all cluster nodes. + * + * @return void + */ + public function flushdb() + { + $this->client->executeCommandOnNodes( + tap(new ServerFlushDatabase)->setArguments(func_get_args()) + ); + } } diff --git a/src/Illuminate/Redis/Connections/PredisConnection.php b/src/Illuminate/Redis/Connections/PredisConnection.php index 932982562ba56dc72e8eab28b74b399c5779238e..e0a8be033f7b6f045d43cdfdb80b9ae90e63f199 100644 --- a/src/Illuminate/Redis/Connections/PredisConnection.php +++ b/src/Illuminate/Redis/Connections/PredisConnection.php @@ -4,8 +4,6 @@ namespace Illuminate\Redis\Connections; use Closure; use Illuminate\Contracts\Redis\Connection as ConnectionContract; -use Predis\Command\ServerFlushDatabase; -use Predis\Connection\Aggregate\ClusterInterface; /** * @mixin \Predis\Client @@ -52,20 +50,4 @@ class PredisConnection extends Connection implements ConnectionContract unset($loop); } - - /** - * Flush the selected Redis database. - * - * @return void - */ - public function flushdb() - { - if (! $this->client->getConnection() instanceof ClusterInterface) { - return $this->command('flushdb'); - } - - foreach ($this->getConnection() as $node) { - $node->executeCommand(new ServerFlushDatabase); - } - } } diff --git a/src/Illuminate/Redis/Connectors/PhpRedisConnector.php b/src/Illuminate/Redis/Connectors/PhpRedisConnector.php index 684737b135fedd9d6b230442c403543a02be557c..1c48bdfaaa51cd3dac4d285209879f3560d2741d 100644 --- a/src/Illuminate/Redis/Connectors/PhpRedisConnector.php +++ b/src/Illuminate/Redis/Connectors/PhpRedisConnector.php @@ -50,7 +50,7 @@ class PhpRedisConnector implements Connector } /** - * Build a single cluster seed string from array. + * Build a single cluster seed string from an array. * * @param array $server * @return string @@ -75,9 +75,9 @@ class PhpRedisConnector implements Connector return tap(new Redis, function ($client) use ($config) { if ($client instanceof RedisFacade) { throw new LogicException( - extension_loaded('redis') - ? 'Please remove or rename the Redis facade alias in your "app" configuration file in order to avoid collision with the PHP Redis extension.' - : 'Please make sure the PHP Redis extension is installed and enabled.' + extension_loaded('redis') + ? 'Please remove or rename the Redis facade alias in your "app" configuration file in order to avoid collision with the PHP Redis extension.' + : 'Please make sure the PHP Redis extension is installed and enabled.' ); } @@ -102,6 +102,22 @@ class PhpRedisConnector implements Connector if (! empty($config['scan'])) { $client->setOption(Redis::OPT_SCAN, $config['scan']); } + + if (! empty($config['name'])) { + $client->client('SETNAME', $config['name']); + } + + if (array_key_exists('serializer', $config)) { + $client->setOption(Redis::OPT_SERIALIZER, $config['serializer']); + } + + if (array_key_exists('compression', $config)) { + $client->setOption(Redis::OPT_COMPRESSION, $config['compression']); + } + + if (array_key_exists('compression_level', $config)) { + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, $config['compression_level']); + } }); } @@ -176,6 +192,22 @@ class PhpRedisConnector implements Connector if (! empty($options['failover'])) { $client->setOption(RedisCluster::OPT_SLAVE_FAILOVER, $options['failover']); } + + if (! empty($options['name'])) { + $client->client('SETNAME', $options['name']); + } + + if (array_key_exists('serializer', $options)) { + $client->setOption(RedisCluster::OPT_SERIALIZER, $options['serializer']); + } + + if (array_key_exists('compression', $options)) { + $client->setOption(RedisCluster::OPT_COMPRESSION, $options['compression']); + } + + if (array_key_exists('compression_level', $options)) { + $client->setOption(RedisCluster::OPT_COMPRESSION_LEVEL, $options['compression_level']); + } }); } diff --git a/src/Illuminate/Redis/Connectors/PredisConnector.php b/src/Illuminate/Redis/Connectors/PredisConnector.php index e91e8956a39834f09907d03ad8528c05084abc85..6222a4b8e9772ba19763b27440712b2effd9e6e8 100644 --- a/src/Illuminate/Redis/Connectors/PredisConnector.php +++ b/src/Illuminate/Redis/Connectors/PredisConnector.php @@ -23,6 +23,10 @@ class PredisConnector implements Connector ['timeout' => 10.0], $options, Arr::pull($config, 'options', []) ); + if (isset($config['prefix'])) { + $formattedOptions['prefix'] = $config['prefix']; + } + return new PredisConnection(new Client($config, $formattedOptions)); } @@ -38,6 +42,10 @@ class PredisConnector implements Connector { $clusterSpecificOptions = Arr::pull($config, 'options', []); + if (isset($config['prefix'])) { + $clusterSpecificOptions['prefix'] = $config['prefix']; + } + return new PredisClusterConnection(new Client(array_values($config), array_merge( $options, $clusterOptions, $clusterSpecificOptions ))); diff --git a/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php b/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php index 2dafa6c9e5d4fdd9ca49cac86b5888030eea22ed..5fcc921e2c1fc104c8a84da5a7262919e0abc584 100644 --- a/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php +++ b/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php @@ -2,9 +2,9 @@ namespace Illuminate\Redis\Limiters; -use Exception; use Illuminate\Contracts\Redis\LimiterTimeoutException; use Illuminate\Support\Str; +use Throwable; class ConcurrencyLimiter { @@ -61,7 +61,7 @@ class ConcurrencyLimiter * @return bool * * @throws \Illuminate\Contracts\Redis\LimiterTimeoutException - * @throws \Exception + * @throws \Throwable */ public function block($timeout, $callback = null) { @@ -82,7 +82,7 @@ class ConcurrencyLimiter return tap($callback(), function () use ($slot, $id) { $this->release($slot, $id); }); - } catch (Exception $exception) { + } catch (Throwable $exception) { $this->release($slot, $id); throw $exception; diff --git a/src/Illuminate/Redis/Limiters/ConcurrencyLimiterBuilder.php b/src/Illuminate/Redis/Limiters/ConcurrencyLimiterBuilder.php index 2ba7c91602d2a9bece0c84b711f19d6f23800a9c..e66259f59b6e611a76af33352816ea25ec76bcd4 100644 --- a/src/Illuminate/Redis/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/Illuminate/Redis/Limiters/ConcurrencyLimiterBuilder.php @@ -58,7 +58,7 @@ class ConcurrencyLimiterBuilder } /** - * Set the maximum number of locks that can obtained per time window. + * Set the maximum number of locks that can be obtained per time window. * * @param int $maxLocks * @return $this diff --git a/src/Illuminate/Redis/Limiters/DurationLimiter.php b/src/Illuminate/Redis/Limiters/DurationLimiter.php index 9aa594fb41f40e30c874acc8ea7f6f7c55ac0a9d..56dbba505435959f1cee5b63c5084dcd0c986c30 100644 --- a/src/Illuminate/Redis/Limiters/DurationLimiter.php +++ b/src/Illuminate/Redis/Limiters/DurationLimiter.php @@ -111,6 +111,30 @@ class DurationLimiter return (bool) $results[0]; } + /** + * Determine if the key has been "accessed" too many times. + * + * @return bool + */ + public function tooManyAttempts() + { + [$this->decaysAt, $this->remaining] = $this->redis->eval( + $this->tooManyAttemptsLuaScript(), 1, $this->name, microtime(true), time(), $this->decay, $this->maxLocks + ); + + return $this->remaining <= 0; + } + + /** + * Clear the limiter. + * + * @return void + */ + public function clear() + { + $this->redis->del($this->name); + } + /** * Get the Lua script for acquiring a lock. * @@ -143,6 +167,36 @@ if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HG end return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1} +LUA; + } + + /** + * Get the Lua script to determine if the key has been "accessed" too many times. + * + * KEYS[1] - The limiter name + * ARGV[1] - Current time in microseconds + * ARGV[2] - Current time in seconds + * ARGV[3] - Duration of the bucket + * ARGV[4] - Allowed number of tasks + * + * @return string + */ + protected function tooManyAttemptsLuaScript() + { + return <<<'LUA' + +if redis.call('EXISTS', KEYS[1]) == 0 then + return {0, ARGV[2] + ARGV[3]} +end + +if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then + return { + redis.call('HGET', KEYS[1], 'end'), + ARGV[4] - redis.call('HGET', KEYS[1], 'count') + } +end + +return {0, ARGV[2] + ARGV[3]} LUA; } } diff --git a/src/Illuminate/Redis/Limiters/DurationLimiterBuilder.php b/src/Illuminate/Redis/Limiters/DurationLimiterBuilder.php index 9d5bfe7d4b8d2fe5af08fa668fa90e74186dc7af..c32cb50f721346688d6ae7811560f33db75f22cb 100644 --- a/src/Illuminate/Redis/Limiters/DurationLimiterBuilder.php +++ b/src/Illuminate/Redis/Limiters/DurationLimiterBuilder.php @@ -24,7 +24,7 @@ class DurationLimiterBuilder public $name; /** - * The maximum number of locks that can obtained per time window. + * The maximum number of locks that can be obtained per time window. * * @var int */ @@ -58,7 +58,7 @@ class DurationLimiterBuilder } /** - * Set the maximum number of locks that can obtained per time window. + * Set the maximum number of locks that can be obtained per time window. * * @param int $maxLocks * @return $this @@ -73,7 +73,7 @@ class DurationLimiterBuilder /** * Set the amount of time the lock window is maintained. * - * @param int $decay + * @param \DateTimeInterface|\DateInterval|int $decay * @return $this */ public function every($decay) diff --git a/src/Illuminate/Redis/RedisManager.php b/src/Illuminate/Redis/RedisManager.php index b5d98203c180460a353e7510d525b340f943c3a8..d69f2116a1530d1a238697e6a2529b8a61c0421c 100644 --- a/src/Illuminate/Redis/RedisManager.php +++ b/src/Illuminate/Redis/RedisManager.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Redis\Factory; use Illuminate\Redis\Connections\Connection; use Illuminate\Redis\Connectors\PhpRedisConnector; use Illuminate\Redis\Connectors\PredisConnector; +use Illuminate\Support\Arr; use Illuminate\Support\ConfigurationUrlParser; use InvalidArgumentException; @@ -108,7 +109,7 @@ class RedisManager implements Factory if (isset($this->config[$name])) { return $this->connector()->connect( $this->parseConnectionConfiguration($this->config[$name]), - $options + array_merge(Arr::except($options, 'parameters'), ['parameters' => Arr::get($options, 'parameters.'.$name, Arr::get($options, 'parameters', []))]) ); } @@ -192,7 +193,7 @@ class RedisManager implements Factory } return array_filter($parsed, function ($key) { - return ! in_array($key, ['driver', 'username'], true); + return ! in_array($key, ['driver'], true); }, ARRAY_FILTER_USE_KEY); } @@ -237,6 +238,19 @@ class RedisManager implements Factory $this->driver = $driver; } + /** + * Disconnect the given connection and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?: 'default'; + + unset($this->connections[$name]); + } + /** * Register a custom driver creator Closure. * diff --git a/src/Illuminate/Redis/composer.json b/src/Illuminate/Redis/composer.json index efb357a093e0117919c9ba0b7fc214d42613f92c..52cd8c98445b34370fea3ee6c8f05fd7f3c9f7cc 100755 --- a/src/Illuminate/Redis/composer.json +++ b/src/Illuminate/Redis/composer.json @@ -14,9 +14,11 @@ } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0" + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -25,11 +27,11 @@ }, "suggest": { "ext-redis": "Required to use the phpredis connector (^4.0|^5.0).", - "predis/predis": "Required to use the predis connector (^1.1.2)." + "predis/predis": "Required to use the predis connector (^1.1.9)." }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Routing/AbstractRouteCollection.php b/src/Illuminate/Routing/AbstractRouteCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..9599d0e45fda1eab01ec7aab4c401002d354d790 --- /dev/null +++ b/src/Illuminate/Routing/AbstractRouteCollection.php @@ -0,0 +1,257 @@ +<?php + +namespace Illuminate\Routing; + +use ArrayIterator; +use Countable; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Str; +use IteratorAggregate; +use LogicException; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper; +use Symfony\Component\Routing\RouteCollection as SymfonyRouteCollection; + +abstract class AbstractRouteCollection implements Countable, IteratorAggregate, RouteCollectionInterface +{ + /** + * Handle the matched route. + * + * @param \Illuminate\Http\Request $request + * @param \Illuminate\Routing\Route|null $route + * @return \Illuminate\Routing\Route + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + protected function handleMatchedRoute(Request $request, $route) + { + if (! is_null($route)) { + return $route->bind($request); + } + + // If no route was found we will now check if a matching route is specified by + // another HTTP verb. If it is we will need to throw a MethodNotAllowed and + // inform the user agent of which HTTP verb it should use for this route. + $others = $this->checkForAlternateVerbs($request); + + if (count($others) > 0) { + return $this->getRouteForMethods($request, $others); + } + + throw new NotFoundHttpException; + } + + /** + * Determine if any routes match on another HTTP verb. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function checkForAlternateVerbs($request) + { + $methods = array_diff(Router::$verbs, [$request->getMethod()]); + + // Here we will spin through all verbs except for the current request verb and + // check to see if any routes respond to them. If they do, we will return a + // proper error response with the correct headers on the response string. + return array_values(array_filter( + $methods, + function ($method) use ($request) { + return ! is_null($this->matchAgainstRoutes($this->get($method), $request, false)); + } + )); + } + + /** + * Determine if a route in the array matches the request. + * + * @param \Illuminate\Routing\Route[] $routes + * @param \Illuminate\Http\Request $request + * @param bool $includingMethod + * @return \Illuminate\Routing\Route|null + */ + protected function matchAgainstRoutes(array $routes, $request, $includingMethod = true) + { + [$fallbacks, $routes] = collect($routes)->partition(function ($route) { + return $route->isFallback; + }); + + return $routes->merge($fallbacks)->first(function (Route $route) use ($request, $includingMethod) { + return $route->matches($request, $includingMethod); + }); + } + + /** + * Get a route (if necessary) that responds when other available methods are present. + * + * @param \Illuminate\Http\Request $request + * @param string[] $methods + * @return \Illuminate\Routing\Route + * + * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException + */ + protected function getRouteForMethods($request, array $methods) + { + if ($request->method() === 'OPTIONS') { + return (new Route('OPTIONS', $request->path(), function () use ($methods) { + return new Response('', 200, ['Allow' => implode(',', $methods)]); + }))->bind($request); + } + + $this->methodNotAllowed($methods, $request->method()); + } + + /** + * Throw a method not allowed HTTP exception. + * + * @param array $others + * @param string $method + * @return void + * + * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException + */ + protected function methodNotAllowed(array $others, $method) + { + throw new MethodNotAllowedHttpException( + $others, + sprintf( + 'The %s method is not supported for this route. Supported methods: %s.', + $method, + implode(', ', $others) + ) + ); + } + + /** + * Compile the routes for caching. + * + * @return array + */ + public function compile() + { + $compiled = $this->dumper()->getCompiledRoutes(); + + $attributes = []; + + foreach ($this->getRoutes() as $route) { + $attributes[$route->getName()] = [ + 'methods' => $route->methods(), + 'uri' => $route->uri(), + 'action' => $route->getAction(), + 'fallback' => $route->isFallback, + 'defaults' => $route->defaults, + 'wheres' => $route->wheres, + 'bindingFields' => $route->bindingFields(), + 'lockSeconds' => $route->locksFor(), + 'waitSeconds' => $route->waitsFor(), + 'withTrashed' => $route->allowsTrashedBindings(), + ]; + } + + return compact('compiled', 'attributes'); + } + + /** + * Return the CompiledUrlMatcherDumper instance for the route collection. + * + * @return \Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper + */ + public function dumper() + { + return new CompiledUrlMatcherDumper($this->toSymfonyRouteCollection()); + } + + /** + * Convert the collection to a Symfony RouteCollection instance. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function toSymfonyRouteCollection() + { + $symfonyRoutes = new SymfonyRouteCollection; + + $routes = $this->getRoutes(); + + foreach ($routes as $route) { + if (! $route->isFallback) { + $symfonyRoutes = $this->addToSymfonyRoutesCollection($symfonyRoutes, $route); + } + } + + foreach ($routes as $route) { + if ($route->isFallback) { + $symfonyRoutes = $this->addToSymfonyRoutesCollection($symfonyRoutes, $route); + } + } + + return $symfonyRoutes; + } + + /** + * Add a route to the SymfonyRouteCollection instance. + * + * @param \Symfony\Component\Routing\RouteCollection $symfonyRoutes + * @param \Illuminate\Routing\Route $route + * @return \Symfony\Component\Routing\RouteCollection + * + * @throws \LogicException + */ + protected function addToSymfonyRoutesCollection(SymfonyRouteCollection $symfonyRoutes, Route $route) + { + $name = $route->getName(); + + if ( + ! is_null($name) + && Str::endsWith($name, '.') + && ! is_null($symfonyRoutes->get($name)) + ) { + $name = null; + } + + if (! $name) { + $route->name($name = $this->generateRouteName()); + + $this->add($route); + } elseif (! is_null($symfonyRoutes->get($name))) { + throw new LogicException("Unable to prepare route [{$route->uri}] for serialization. Another route has already been assigned name [{$name}]."); + } + + $symfonyRoutes->add($route->getName(), $route->toSymfonyRoute()); + + return $symfonyRoutes; + } + + /** + * Get a randomly generated route name. + * + * @return string + */ + protected function generateRouteName() + { + return 'generated::'.Str::random(); + } + + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->getRoutes()); + } + + /** + * Count the number of items in the collection. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + return count($this->getRoutes()); + } +} diff --git a/src/Illuminate/Routing/CompiledRouteCollection.php b/src/Illuminate/Routing/CompiledRouteCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..2b693f85f25acaa1d5d86f0b58ca7d0c6a1e4c76 --- /dev/null +++ b/src/Illuminate/Routing/CompiledRouteCollection.php @@ -0,0 +1,330 @@ +<?php + +namespace Illuminate\Routing; + +use Illuminate\Container\Container; +use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\Matcher\CompiledUrlMatcher; +use Symfony\Component\Routing\RequestContext; + +class CompiledRouteCollection extends AbstractRouteCollection +{ + /** + * The compiled routes collection. + * + * @var array + */ + protected $compiled = []; + + /** + * An array of the route attributes keyed by name. + * + * @var array + */ + protected $attributes = []; + + /** + * The dynamically added routes that were added after loading the cached, compiled routes. + * + * @var \Illuminate\Routing\RouteCollection|null + */ + protected $routes; + + /** + * The router instance used by the route. + * + * @var \Illuminate\Routing\Router + */ + protected $router; + + /** + * The container instance used by the route. + * + * @var \Illuminate\Container\Container + */ + protected $container; + + /** + * Create a new CompiledRouteCollection instance. + * + * @param array $compiled + * @param array $attributes + * @return void + */ + public function __construct(array $compiled, array $attributes) + { + $this->compiled = $compiled; + $this->attributes = $attributes; + $this->routes = new RouteCollection; + } + + /** + * Add a Route instance to the collection. + * + * @param \Illuminate\Routing\Route $route + * @return \Illuminate\Routing\Route + */ + public function add(Route $route) + { + return $this->routes->add($route); + } + + /** + * Refresh the name look-up table. + * + * This is done in case any names are fluently defined or if routes are overwritten. + * + * @return void + */ + public function refreshNameLookups() + { + // + } + + /** + * Refresh the action look-up table. + * + * This is done in case any actions are overwritten with new controllers. + * + * @return void + */ + public function refreshActionLookups() + { + // + } + + /** + * Find the first route matching a given request. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Routing\Route + * + * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public function match(Request $request) + { + $matcher = new CompiledUrlMatcher( + $this->compiled, (new RequestContext)->fromRequest( + $trimmedRequest = $this->requestWithoutTrailingSlash($request) + ) + ); + + $route = null; + + try { + if ($result = $matcher->matchRequest($trimmedRequest)) { + $route = $this->getByName($result['_route']); + } + } catch (ResourceNotFoundException|MethodNotAllowedException $e) { + try { + return $this->routes->match($request); + } catch (NotFoundHttpException $e) { + // + } + } + + if ($route && $route->isFallback) { + try { + $dynamicRoute = $this->routes->match($request); + + if (! $dynamicRoute->isFallback) { + $route = $dynamicRoute; + } + } catch (NotFoundHttpException|MethodNotAllowedHttpException $e) { + // + } + } + + return $this->handleMatchedRoute($request, $route); + } + + /** + * Get a cloned instance of the given request without any trailing slash on the URI. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Request + */ + protected function requestWithoutTrailingSlash(Request $request) + { + $trimmedRequest = $request->duplicate(); + + $parts = explode('?', $request->server->get('REQUEST_URI'), 2); + + $trimmedRequest->server->set( + 'REQUEST_URI', rtrim($parts[0], '/').(isset($parts[1]) ? '?'.$parts[1] : '') + ); + + return $trimmedRequest; + } + + /** + * Get routes from the collection by method. + * + * @param string|null $method + * @return \Illuminate\Routing\Route[] + */ + public function get($method = null) + { + return $this->getRoutesByMethod()[$method] ?? []; + } + + /** + * Determine if the route collection contains a given named route. + * + * @param string $name + * @return bool + */ + public function hasNamedRoute($name) + { + return isset($this->attributes[$name]) || $this->routes->hasNamedRoute($name); + } + + /** + * Get a route instance by its name. + * + * @param string $name + * @return \Illuminate\Routing\Route|null + */ + public function getByName($name) + { + if (isset($this->attributes[$name])) { + return $this->newRoute($this->attributes[$name]); + } + + return $this->routes->getByName($name); + } + + /** + * Get a route instance by its controller action. + * + * @param string $action + * @return \Illuminate\Routing\Route|null + */ + public function getByAction($action) + { + $attributes = collect($this->attributes)->first(function (array $attributes) use ($action) { + if (isset($attributes['action']['controller'])) { + return trim($attributes['action']['controller'], '\\') === $action; + } + + return $attributes['action']['uses'] === $action; + }); + + if ($attributes) { + return $this->newRoute($attributes); + } + + return $this->routes->getByAction($action); + } + + /** + * Get all of the routes in the collection. + * + * @return \Illuminate\Routing\Route[] + */ + public function getRoutes() + { + return collect($this->attributes) + ->map(function (array $attributes) { + return $this->newRoute($attributes); + }) + ->merge($this->routes->getRoutes()) + ->values() + ->all(); + } + + /** + * Get all of the routes keyed by their HTTP verb / method. + * + * @return array + */ + public function getRoutesByMethod() + { + return collect($this->getRoutes()) + ->groupBy(function (Route $route) { + return $route->methods(); + }) + ->map(function (Collection $routes) { + return $routes->mapWithKeys(function (Route $route) { + return [$route->getDomain().$route->uri => $route]; + })->all(); + }) + ->all(); + } + + /** + * Get all of the routes keyed by their name. + * + * @return \Illuminate\Routing\Route[] + */ + public function getRoutesByName() + { + return collect($this->getRoutes()) + ->keyBy(function (Route $route) { + return $route->getName(); + }) + ->all(); + } + + /** + * Resolve an array of attributes to a Route instance. + * + * @param array $attributes + * @return \Illuminate\Routing\Route + */ + protected function newRoute(array $attributes) + { + if (empty($attributes['action']['prefix'] ?? '')) { + $baseUri = $attributes['uri']; + } else { + $prefix = trim($attributes['action']['prefix'], '/'); + + $baseUri = trim(implode( + '/', array_slice( + explode('/', trim($attributes['uri'], '/')), + count($prefix !== '' ? explode('/', $prefix) : []) + ) + ), '/'); + } + + return $this->router->newRoute($attributes['methods'], $baseUri === '' ? '/' : $baseUri, $attributes['action']) + ->setFallback($attributes['fallback']) + ->setDefaults($attributes['defaults']) + ->setWheres($attributes['wheres']) + ->setBindingFields($attributes['bindingFields']) + ->block($attributes['lockSeconds'] ?? null, $attributes['waitSeconds'] ?? null) + ->withTrashed($attributes['withTrashed'] ?? false); + } + + /** + * Set the router instance on the route. + * + * @param \Illuminate\Routing\Router $router + * @return $this + */ + public function setRouter(Router $router) + { + $this->router = $router; + + return $this; + } + + /** + * Set the container instance on the route. + * + * @param \Illuminate\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } +} diff --git a/src/Illuminate/Routing/Console/ControllerMakeCommand.php b/src/Illuminate/Routing/Console/ControllerMakeCommand.php index 258ec5dd384ca1123b39d3d63c31eb3313f1d517..fe31bea6e2b28c4350982e02289c65b1584e2455 100755 --- a/src/Illuminate/Routing/Console/ControllerMakeCommand.php +++ b/src/Illuminate/Routing/Console/ControllerMakeCommand.php @@ -2,13 +2,15 @@ namespace Illuminate\Routing\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; -use Illuminate\Support\Str; use InvalidArgumentException; use Symfony\Component\Console\Input\InputOption; class ControllerMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -39,7 +41,9 @@ class ControllerMakeCommand extends GeneratorCommand { $stub = null; - if ($this->option('parent')) { + if ($type = $this->option('type')) { + $stub = "/stubs/controller.{$type}.stub"; + } elseif ($this->option('parent')) { $stub = '/stubs/controller.nested.stub'; } elseif ($this->option('model')) { $stub = '/stubs/controller.model.stub'; @@ -57,7 +61,20 @@ class ControllerMakeCommand extends GeneratorCommand $stub = $stub ?? '/stubs/controller.plain.stub'; - return __DIR__.$stub; + return $this->resolveStubPath($stub); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** @@ -74,7 +91,7 @@ class ControllerMakeCommand extends GeneratorCommand /** * Build the class with the given name. * - * Remove the base controller import if we are already in base namespace. + * Remove the base controller import if we are already in the base namespace. * * @param string $name * @return string @@ -117,8 +134,14 @@ class ControllerMakeCommand extends GeneratorCommand return [ 'ParentDummyFullModelClass' => $parentModelClass, + '{{ namespacedParentModel }}' => $parentModelClass, + '{{namespacedParentModel}}' => $parentModelClass, 'ParentDummyModelClass' => class_basename($parentModelClass), + '{{ parentModel }}' => class_basename($parentModelClass), + '{{parentModel}}' => class_basename($parentModelClass), 'ParentDummyModelVariable' => lcfirst(class_basename($parentModelClass)), + '{{ parentModelVariable }}' => lcfirst(class_basename($parentModelClass)), + '{{parentModelVariable}}' => lcfirst(class_basename($parentModelClass)), ]; } @@ -138,10 +161,18 @@ class ControllerMakeCommand extends GeneratorCommand } } + $replace = $this->buildFormRequestReplacements($replace, $modelClass); + return array_merge($replace, [ 'DummyFullModelClass' => $modelClass, + '{{ namespacedModel }}' => $modelClass, + '{{namespacedModel}}' => $modelClass, 'DummyModelClass' => class_basename($modelClass), + '{{ model }}' => class_basename($modelClass), + '{{model}}' => class_basename($modelClass), 'DummyModelVariable' => lcfirst(class_basename($modelClass)), + '{{ modelVariable }}' => lcfirst(class_basename($modelClass)), + '{{modelVariable}}' => lcfirst(class_basename($modelClass)), ]); } @@ -159,13 +190,73 @@ class ControllerMakeCommand extends GeneratorCommand throw new InvalidArgumentException('Model name contains invalid characters.'); } - $model = trim(str_replace('/', '\\', $model), '\\'); + return $this->qualifyModel($model); + } + + /** + * Build the model replacement values. + * + * @param array $replace + * @param string $modelClass + * @return array + */ + protected function buildFormRequestReplacements(array $replace, $modelClass) + { + [$namespace, $storeRequestClass, $updateRequestClass] = [ + 'Illuminate\\Http', 'Request', 'Request', + ]; - if (! Str::startsWith($model, $rootNamespace = $this->laravel->getNamespace())) { - $model = $rootNamespace.$model; + if ($this->option('requests')) { + $namespace = 'App\\Http\\Requests'; + + [$storeRequestClass, $updateRequestClass] = $this->generateFormRequests( + $modelClass, $storeRequestClass, $updateRequestClass + ); } - return $model; + $namespacedRequests = $namespace.'\\'.$storeRequestClass.';'; + + if ($storeRequestClass !== $updateRequestClass) { + $namespacedRequests .= PHP_EOL.'use '.$namespace.'\\'.$updateRequestClass.';'; + } + + return array_merge($replace, [ + '{{ storeRequest }}' => $storeRequestClass, + '{{storeRequest}}' => $storeRequestClass, + '{{ updateRequest }}' => $updateRequestClass, + '{{updateRequest}}' => $updateRequestClass, + '{{ namespacedStoreRequest }}' => $namespace.'\\'.$storeRequestClass, + '{{namespacedStoreRequest}}' => $namespace.'\\'.$storeRequestClass, + '{{ namespacedUpdateRequest }}' => $namespace.'\\'.$updateRequestClass, + '{{namespacedUpdateRequest}}' => $namespace.'\\'.$updateRequestClass, + '{{ namespacedRequests }}' => $namespacedRequests, + '{{namespacedRequests}}' => $namespacedRequests, + ]); + } + + /** + * Generate the form requests for the given model and classes. + * + * @param string $modelName + * @param string $storeRequestClass + * @param string $updateRequestClass + * @return array + */ + protected function generateFormRequests($modelClass, $storeRequestClass, $updateRequestClass) + { + $storeRequestClass = 'Store'.class_basename($modelClass).'Request'; + + $this->call('make:request', [ + 'name' => $storeRequestClass, + ]); + + $updateRequestClass = 'Update'.class_basename($modelClass).'Request'; + + $this->call('make:request', [ + 'name' => $updateRequestClass, + ]); + + return [$storeRequestClass, $updateRequestClass]; } /** @@ -177,11 +268,13 @@ class ControllerMakeCommand extends GeneratorCommand { return [ ['api', null, InputOption::VALUE_NONE, 'Exclude the create and edit methods from the controller.'], + ['type', null, InputOption::VALUE_REQUIRED, 'Manually specify the controller stub file to use.'], ['force', null, InputOption::VALUE_NONE, 'Create the class even if the controller already exists'], ['invokable', 'i', InputOption::VALUE_NONE, 'Generate a single method, invokable controller class.'], ['model', 'm', InputOption::VALUE_OPTIONAL, 'Generate a resource controller for the given model.'], ['parent', 'p', InputOption::VALUE_OPTIONAL, 'Generate a nested resource controller class.'], ['resource', 'r', InputOption::VALUE_NONE, 'Generate a resource controller class.'], + ['requests', 'R', InputOption::VALUE_NONE, 'Generate FormRequest classes for store and update.'], ]; } } diff --git a/src/Illuminate/Routing/Console/MiddlewareMakeCommand.php b/src/Illuminate/Routing/Console/MiddlewareMakeCommand.php index e41813d32966f05a76752909950f2c57eb126216..ddd591c0fcc48882fb80e79a605d9ace3d183600 100644 --- a/src/Illuminate/Routing/Console/MiddlewareMakeCommand.php +++ b/src/Illuminate/Routing/Console/MiddlewareMakeCommand.php @@ -2,10 +2,13 @@ namespace Illuminate\Routing\Console; +use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; class MiddlewareMakeCommand extends GeneratorCommand { + use CreatesMatchingTest; + /** * The console command name. * @@ -34,7 +37,20 @@ class MiddlewareMakeCommand extends GeneratorCommand */ protected function getStub() { - return __DIR__.'/stubs/middleware.stub'; + return $this->resolveStubPath('/stubs/middleware.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; } /** diff --git a/src/Illuminate/Routing/Console/stubs/controller.api.stub b/src/Illuminate/Routing/Console/stubs/controller.api.stub index 4c35118bc84f042bce366e27130ee35532ad8772..381a9c87f430c4435fefa6c626a7aac7556e3c38 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.api.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.api.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. diff --git a/src/Illuminate/Routing/Console/stubs/controller.invokable.stub b/src/Illuminate/Routing/Console/stubs/controller.invokable.stub index fa46995ec52e91d01704da26a38bdb4878a8d176..12d291cce96cb2adffd49c2e689a6770b2288173 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.invokable.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.invokable.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Handle the incoming request. diff --git a/src/Illuminate/Routing/Console/stubs/controller.model.api.stub b/src/Illuminate/Routing/Console/stubs/controller.model.api.stub index 96068e574b7b7add8a5bc022bc40791815371edc..4da21ed0587a0a578c8ee1a42644f62cfc9fa445 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.model.api.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.model.api.stub @@ -1,12 +1,12 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullModelClass; -use DummyRootNamespaceHttp\Controllers\Controller; -use Illuminate\Http\Request; +use {{ namespacedModel }}; +use {{ rootNamespace }}Http\Controllers\Controller; +use {{ namespacedRequests }} -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. @@ -21,10 +21,10 @@ class DummyClass extends Controller /** * Store a newly created resource in storage. * - * @param \Illuminate\Http\Request $request + * @param \{{ namespacedStoreRequest }} $request * @return \Illuminate\Http\Response */ - public function store(Request $request) + public function store({{ storeRequest }} $request) { // } @@ -32,10 +32,10 @@ class DummyClass extends Controller /** * Display the specified resource. * - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function show(DummyModelClass $DummyModelVariable) + public function show({{ model }} ${{ modelVariable }}) { // } @@ -43,11 +43,11 @@ class DummyClass extends Controller /** * Update the specified resource in storage. * - * @param \Illuminate\Http\Request $request - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedUpdateRequest }} $request + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function update(Request $request, DummyModelClass $DummyModelVariable) + public function update({{ updateRequest }} $request, {{ model }} ${{ modelVariable }}) { // } @@ -55,10 +55,10 @@ class DummyClass extends Controller /** * Remove the specified resource from storage. * - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function destroy(DummyModelClass $DummyModelVariable) + public function destroy({{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Routing/Console/stubs/controller.model.stub b/src/Illuminate/Routing/Console/stubs/controller.model.stub index ce659523d0ab57ae70ae4afd97f47738ce92fe29..1396bd410d5cff068b814177ceb2f616375c8c52 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.model.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.model.stub @@ -1,12 +1,12 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullModelClass; -use DummyRootNamespaceHttp\Controllers\Controller; -use Illuminate\Http\Request; +use {{ namespacedModel }}; +use {{ rootNamespace }}Http\Controllers\Controller; +use {{ namespacedRequests }} -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. @@ -31,10 +31,10 @@ class DummyClass extends Controller /** * Store a newly created resource in storage. * - * @param \Illuminate\Http\Request $request + * @param \{{ namespacedStoreRequest }} $request * @return \Illuminate\Http\Response */ - public function store(Request $request) + public function store({{ storeRequest }} $request) { // } @@ -42,10 +42,10 @@ class DummyClass extends Controller /** * Display the specified resource. * - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function show(DummyModelClass $DummyModelVariable) + public function show({{ model }} ${{ modelVariable }}) { // } @@ -53,10 +53,10 @@ class DummyClass extends Controller /** * Show the form for editing the specified resource. * - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function edit(DummyModelClass $DummyModelVariable) + public function edit({{ model }} ${{ modelVariable }}) { // } @@ -64,11 +64,11 @@ class DummyClass extends Controller /** * Update the specified resource in storage. * - * @param \Illuminate\Http\Request $request - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedUpdateRequest }} $request + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function update(Request $request, DummyModelClass $DummyModelVariable) + public function update({{ updateRequest }} $request, {{ model }} ${{ modelVariable }}) { // } @@ -76,10 +76,10 @@ class DummyClass extends Controller /** * Remove the specified resource from storage. * - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function destroy(DummyModelClass $DummyModelVariable) + public function destroy({{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Routing/Console/stubs/controller.nested.api.stub b/src/Illuminate/Routing/Console/stubs/controller.nested.api.stub index 82818642debe174082e99e5bb8fedb1cbdfc8108..c5fda605fae8929e130d4ff41240cd698d42ad97 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.nested.api.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.nested.api.stub @@ -1,21 +1,21 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullModelClass; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ namespacedModel }}; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -use ParentDummyFullModelClass; +use {{ namespacedParentModel }}; -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} * @return \Illuminate\Http\Response */ - public function index(ParentDummyModelClass $ParentDummyModelVariable) + public function index({{ parentModel }} ${{ parentModelVariable }}) { // } @@ -24,10 +24,10 @@ class DummyClass extends Controller * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request - * @param \ParentDummyFullModelClass $ParentDummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} * @return \Illuminate\Http\Response */ - public function store(Request $request, ParentDummyModelClass $ParentDummyModelVariable) + public function store(Request $request, {{ parentModel }} ${{ parentModelVariable }}) { // } @@ -35,11 +35,11 @@ class DummyClass extends Controller /** * Display the specified resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function show(ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function show({{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } @@ -48,11 +48,11 @@ class DummyClass extends Controller * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function update(Request $request, ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function update(Request $request, {{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } @@ -60,11 +60,11 @@ class DummyClass extends Controller /** * Remove the specified resource from storage. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function destroy(ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function destroy({{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Routing/Console/stubs/controller.nested.stub b/src/Illuminate/Routing/Console/stubs/controller.nested.stub index 6d458bd3edb46284e6a695866122b2d0a2956ce8..ed6a095537ad8c7c9381f2d5b32167ad76149b9d 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.nested.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.nested.stub @@ -1,21 +1,21 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyFullModelClass; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ namespacedModel }}; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -use ParentDummyFullModelClass; +use {{ namespacedParentModel }}; -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} * @return \Illuminate\Http\Response */ - public function index(ParentDummyModelClass $ParentDummyModelVariable) + public function index({{ parentModel }} ${{ parentModelVariable }}) { // } @@ -23,10 +23,10 @@ class DummyClass extends Controller /** * Show the form for creating a new resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} * @return \Illuminate\Http\Response */ - public function create(ParentDummyModelClass $ParentDummyModelVariable) + public function create({{ parentModel }} ${{ parentModelVariable }}) { // } @@ -35,10 +35,10 @@ class DummyClass extends Controller * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request - * @param \ParentDummyFullModelClass $ParentDummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} * @return \Illuminate\Http\Response */ - public function store(Request $request, ParentDummyModelClass $ParentDummyModelVariable) + public function store(Request $request, {{ parentModel }} ${{ parentModelVariable }}) { // } @@ -46,11 +46,11 @@ class DummyClass extends Controller /** * Display the specified resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function show(ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function show({{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } @@ -58,11 +58,11 @@ class DummyClass extends Controller /** * Show the form for editing the specified resource. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function edit(ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function edit({{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } @@ -71,11 +71,11 @@ class DummyClass extends Controller * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function update(Request $request, ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function update(Request $request, {{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } @@ -83,11 +83,11 @@ class DummyClass extends Controller /** * Remove the specified resource from storage. * - * @param \ParentDummyFullModelClass $ParentDummyModelVariable - * @param \DummyFullModelClass $DummyModelVariable + * @param \{{ namespacedParentModel }} ${{ parentModelVariable }} + * @param \{{ namespacedModel }} ${{ modelVariable }} * @return \Illuminate\Http\Response */ - public function destroy(ParentDummyModelClass $ParentDummyModelVariable, DummyModelClass $DummyModelVariable) + public function destroy({{ parentModel }} ${{ parentModelVariable }}, {{ model }} ${{ modelVariable }}) { // } diff --git a/src/Illuminate/Routing/Console/stubs/controller.plain.stub b/src/Illuminate/Routing/Console/stubs/controller.plain.stub index 9936e3dc2dd58ed778ec1dbe87658525e28e038b..da07e7d85787a4a0880d76392d12f73df8d1b73d 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.plain.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.plain.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -class DummyClass extends Controller +class {{ class }} extends Controller { // } diff --git a/src/Illuminate/Routing/Console/stubs/controller.stub b/src/Illuminate/Routing/Console/stubs/controller.stub index 2580a4f02454bbf7c4283383caa86507a7be2c24..2e6b620f95f42e2c2cf180b7c53f6ba86c8bd7ad 100644 --- a/src/Illuminate/Routing/Console/stubs/controller.stub +++ b/src/Illuminate/Routing/Console/stubs/controller.stub @@ -1,11 +1,11 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; -use DummyRootNamespaceHttp\Controllers\Controller; +use {{ rootNamespace }}Http\Controllers\Controller; use Illuminate\Http\Request; -class DummyClass extends Controller +class {{ class }} extends Controller { /** * Display a listing of the resource. diff --git a/src/Illuminate/Routing/Console/stubs/middleware.stub b/src/Illuminate/Routing/Console/stubs/middleware.stub index 1a5b1496662b44ff0ef08747e8851cdb74fce921..855594c4660a9ba809e4069c32e6943d901aeefc 100644 --- a/src/Illuminate/Routing/Console/stubs/middleware.stub +++ b/src/Illuminate/Routing/Console/stubs/middleware.stub @@ -1,19 +1,20 @@ <?php -namespace DummyNamespace; +namespace {{ namespace }}; use Closure; +use Illuminate\Http\Request; -class DummyClass +class {{ class }} { /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed + * @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next + * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse */ - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next) { return $next($request); } diff --git a/src/Illuminate/Routing/CreatesRegularExpressionRouteConstraints.php b/src/Illuminate/Routing/CreatesRegularExpressionRouteConstraints.php new file mode 100644 index 0000000000000000000000000000000000000000..31b0f7166d045c52b25749cadd4227b47c1838a6 --- /dev/null +++ b/src/Illuminate/Routing/CreatesRegularExpressionRouteConstraints.php @@ -0,0 +1,67 @@ +<?php + +namespace Illuminate\Routing; + +use Illuminate\Support\Arr; + +trait CreatesRegularExpressionRouteConstraints +{ + /** + * Specify that the given route parameters must be alphabetic. + * + * @param array|string $parameters + * @return $this + */ + public function whereAlpha($parameters) + { + return $this->assignExpressionToParameters($parameters, '[a-zA-Z]+'); + } + + /** + * Specify that the given route parameters must be alphanumeric. + * + * @param array|string $parameters + * @return $this + */ + public function whereAlphaNumeric($parameters) + { + return $this->assignExpressionToParameters($parameters, '[a-zA-Z0-9]+'); + } + + /** + * Specify that the given route parameters must be numeric. + * + * @param array|string $parameters + * @return $this + */ + public function whereNumber($parameters) + { + return $this->assignExpressionToParameters($parameters, '[0-9]+'); + } + + /** + * Specify that the given route parameters must be UUIDs. + * + * @param array|string $parameters + * @return $this + */ + public function whereUuid($parameters) + { + return $this->assignExpressionToParameters($parameters, '[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}'); + } + + /** + * Apply the given regular expression to the given parameters. + * + * @param array|string $parameters + * @param string $expression + * @return $this + */ + protected function assignExpressionToParameters($parameters, $expression) + { + return $this->where(collect(Arr::wrap($parameters)) + ->mapWithKeys(function ($parameter) use ($expression) { + return [$parameter => $expression]; + })->all()); + } +} diff --git a/src/Illuminate/Routing/Exceptions/UrlGenerationException.php b/src/Illuminate/Routing/Exceptions/UrlGenerationException.php index 1853b2aff0fc06609281fbf5463e9127eb2d9ed7..eadda8010c0f01555e6d9beb6fbebc94053ae3c9 100644 --- a/src/Illuminate/Routing/Exceptions/UrlGenerationException.php +++ b/src/Illuminate/Routing/Exceptions/UrlGenerationException.php @@ -3,6 +3,8 @@ namespace Illuminate\Routing\Exceptions; use Exception; +use Illuminate\Routing\Route; +use Illuminate\Support\Str; class UrlGenerationException extends Exception { @@ -10,10 +12,26 @@ class UrlGenerationException extends Exception * Create a new exception for missing route parameters. * * @param \Illuminate\Routing\Route $route + * @param array $parameters * @return static */ - public static function forMissingParameters($route) + public static function forMissingParameters(Route $route, array $parameters = []) { - return new static("Missing required parameters for [Route: {$route->getName()}] [URI: {$route->uri()}]."); + $parameterLabel = Str::plural('parameter', count($parameters)); + + $message = sprintf( + 'Missing required %s for [Route: %s] [URI: %s]', + $parameterLabel, + $route->getName(), + $route->uri() + ); + + if (count($parameters) > 0) { + $message .= sprintf(' [Missing %s: %s]', $parameterLabel, implode(', ', $parameters)); + } + + $message .= '.'; + + return new static($message); } } diff --git a/src/Illuminate/Routing/ImplicitRouteBinding.php b/src/Illuminate/Routing/ImplicitRouteBinding.php index e30372dab807d93ceb6e02730cb0f7e6a3593265..1dbc5720fd82b0c9867c737fb044a18f9cad2232 100644 --- a/src/Illuminate/Routing/ImplicitRouteBinding.php +++ b/src/Illuminate/Routing/ImplicitRouteBinding.php @@ -4,6 +4,7 @@ namespace Illuminate\Routing; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Reflector; use Illuminate\Support\Str; @@ -35,7 +36,23 @@ class ImplicitRouteBinding $instance = $container->make(Reflector::getParameterClassName($parameter)); - if (! $model = $instance->resolveRouteBinding($parameterValue)) { + $parent = $route->parentOfParameter($parameterName); + + $routeBindingMethod = $route->allowsTrashedBindings() && in_array(SoftDeletes::class, class_uses_recursive($instance)) + ? 'resolveSoftDeletableRouteBinding' + : 'resolveRouteBinding'; + + if ($parent instanceof UrlRoutable && ($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) { + $childRouteBindingMethod = $route->allowsTrashedBindings() && in_array(SoftDeletes::class, class_uses_recursive($instance)) + ? 'resolveSoftDeletableChildRouteBinding' + : 'resolveChildRouteBinding'; + + if (! $model = $parent->{$childRouteBindingMethod}( + $parameterName, $parameterValue, $route->bindingFieldFor($parameterName) + )) { + throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]); + } + } elseif (! $model = $instance->{$routeBindingMethod}($parameterValue, $route->bindingFieldFor($parameterName))) { throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]); } diff --git a/src/Illuminate/Routing/Matching/UriValidator.php b/src/Illuminate/Routing/Matching/UriValidator.php index 3aeb73b2d176c1f66d19130319d3a898849bfe07..4dcc2cf34d691e6dd1187875158f9265bfaf983c 100644 --- a/src/Illuminate/Routing/Matching/UriValidator.php +++ b/src/Illuminate/Routing/Matching/UriValidator.php @@ -16,7 +16,7 @@ class UriValidator implements ValidatorInterface */ public function matches(Route $route, Request $request) { - $path = $request->path() === '/' ? '/' : '/'.$request->path(); + $path = rtrim($request->getPathInfo(), '/') ?: '/'; return preg_match($route->getCompiled()->getRegex(), rawurldecode($path)); } diff --git a/src/Illuminate/Routing/Middleware/SubstituteBindings.php b/src/Illuminate/Routing/Middleware/SubstituteBindings.php index 57adde76e915c874b159f5a9c5ee3d246ace319a..b3624de51d29bbf2eb4f8ef823b730bbddc5e324 100644 --- a/src/Illuminate/Routing/Middleware/SubstituteBindings.php +++ b/src/Illuminate/Routing/Middleware/SubstituteBindings.php @@ -4,6 +4,7 @@ namespace Illuminate\Routing\Middleware; use Closure; use Illuminate\Contracts\Routing\Registrar; +use Illuminate\Database\Eloquent\ModelNotFoundException; class SubstituteBindings { @@ -34,9 +35,17 @@ class SubstituteBindings */ public function handle($request, Closure $next) { - $this->router->substituteBindings($route = $request->route()); + try { + $this->router->substituteBindings($route = $request->route()); - $this->router->substituteImplicitBindings($route); + $this->router->substituteImplicitBindings($route); + } catch (ModelNotFoundException $exception) { + if ($route->getMissing()) { + return $route->getMissing()($request, $exception); + } + + throw $exception; + } return $next($request); } diff --git a/src/Illuminate/Routing/Middleware/ThrottleRequests.php b/src/Illuminate/Routing/Middleware/ThrottleRequests.php index a06b2c291c50ad6dedcc8d77721337ee20a2cc95..d570da44093f736ce2258cea9c06489083b38edc 100644 --- a/src/Illuminate/Routing/Middleware/ThrottleRequests.php +++ b/src/Illuminate/Routing/Middleware/ThrottleRequests.php @@ -4,7 +4,10 @@ namespace Illuminate\Routing\Middleware; use Closure; use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\RateLimiting\Unlimited; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Support\Arr; use Illuminate\Support\InteractsWithTime; use Illuminate\Support\Str; use RuntimeException; @@ -46,22 +49,92 @@ class ThrottleRequests */ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '') { - $key = $prefix.$this->resolveRequestSignature($request); + if (is_string($maxAttempts) + && func_num_args() === 3 + && ! is_null($limiter = $this->limiter->limiter($maxAttempts))) { + return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter); + } - $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts); + return $this->handleRequest( + $request, + $next, + [ + (object) [ + 'key' => $prefix.$this->resolveRequestSignature($request), + 'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts), + 'decayMinutes' => $decayMinutes, + 'responseCallback' => null, + ], + ] + ); + } - if ($this->limiter->tooManyAttempts($key, $maxAttempts)) { - throw $this->buildException($key, $maxAttempts); + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string $limiterName + * @param \Closure $limiter + * @return \Symfony\Component\HttpFoundation\Response + * + * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException + */ + protected function handleRequestUsingNamedLimiter($request, Closure $next, $limiterName, Closure $limiter) + { + $limiterResponse = call_user_func($limiter, $request); + + if ($limiterResponse instanceof Response) { + return $limiterResponse; + } elseif ($limiterResponse instanceof Unlimited) { + return $next($request); } - $this->limiter->hit($key, $decayMinutes * 60); + return $this->handleRequest( + $request, + $next, + collect(Arr::wrap($limiterResponse))->map(function ($limit) use ($limiterName) { + return (object) [ + 'key' => md5($limiterName.$limit->key), + 'maxAttempts' => $limit->maxAttempts, + 'decayMinutes' => $limit->decayMinutes, + 'responseCallback' => $limit->responseCallback, + ]; + })->all() + ); + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param array $limits + * @return \Symfony\Component\HttpFoundation\Response + * + * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException + */ + protected function handleRequest($request, Closure $next, array $limits) + { + foreach ($limits as $limit) { + if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { + throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); + } + + $this->limiter->hit($limit->key, $limit->decayMinutes * 60); + } $response = $next($request); - return $this->addHeaders( - $response, $maxAttempts, - $this->calculateRemainingAttempts($key, $maxAttempts) - ); + foreach ($limits as $limit) { + $response = $this->addHeaders( + $response, + $limit->maxAttempts, + $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts) + ); + } + + return $response; } /** @@ -96,9 +169,7 @@ class ThrottleRequests { if ($user = $request->user()) { return sha1($user->getAuthIdentifier()); - } - - if ($route = $request->route()) { + } elseif ($route = $request->route()) { return sha1($route->getDomain().'|'.$request->ip()); } @@ -108,11 +179,13 @@ class ThrottleRequests /** * Create a 'too many attempts' exception. * + * @param \Illuminate\Http\Request $request * @param string $key * @param int $maxAttempts + * @param callable|null $responseCallback * @return \Illuminate\Http\Exceptions\ThrottleRequestsException */ - protected function buildException($key, $maxAttempts) + protected function buildException($request, $key, $maxAttempts, $responseCallback = null) { $retryAfter = $this->getTimeUntilNextRetry($key); @@ -122,9 +195,9 @@ class ThrottleRequests $retryAfter ); - return new ThrottleRequestsException( - 'Too Many Attempts.', null, $headers - ); + return is_callable($responseCallback) + ? new HttpResponseException($responseCallback($request, $headers)) + : new ThrottleRequestsException('Too Many Attempts.', null, $headers); } /** @@ -150,7 +223,7 @@ class ThrottleRequests protected function addHeaders(Response $response, $maxAttempts, $remainingAttempts, $retryAfter = null) { $response->headers->add( - $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter) + $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter, $response) ); return $response; @@ -162,10 +235,20 @@ class ThrottleRequests * @param int $maxAttempts * @param int $remainingAttempts * @param int|null $retryAfter + * @param \Symfony\Component\HttpFoundation\Response|null $response * @return array */ - protected function getHeaders($maxAttempts, $remainingAttempts, $retryAfter = null) + protected function getHeaders($maxAttempts, + $remainingAttempts, + $retryAfter = null, + ?Response $response = null) { + if ($response && + ! is_null($response->headers->get('X-RateLimit-Remaining')) && + (int) $response->headers->get('X-RateLimit-Remaining') <= (int) $remainingAttempts) { + return []; + } + $headers = [ 'X-RateLimit-Limit' => $maxAttempts, 'X-RateLimit-Remaining' => $remainingAttempts, @@ -189,10 +272,6 @@ class ThrottleRequests */ protected function calculateRemainingAttempts($key, $maxAttempts, $retryAfter = null) { - if (is_null($retryAfter)) { - return $this->limiter->retriesLeft($key, $maxAttempts); - } - - return 0; + return is_null($retryAfter) ? $this->limiter->retriesLeft($key, $maxAttempts) : 0; } } diff --git a/src/Illuminate/Routing/Middleware/ThrottleRequestsWithRedis.php b/src/Illuminate/Routing/Middleware/ThrottleRequestsWithRedis.php index 665f88280b58f9d458018ad765daab0303ad32e3..777f30c07e2d1de8f5761e286064254dee96d42b 100644 --- a/src/Illuminate/Routing/Middleware/ThrottleRequestsWithRedis.php +++ b/src/Illuminate/Routing/Middleware/ThrottleRequestsWithRedis.php @@ -3,6 +3,7 @@ namespace Illuminate\Routing\Middleware; use Closure; +use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Redis\Limiters\DurationLimiter; @@ -16,27 +17,30 @@ class ThrottleRequestsWithRedis extends ThrottleRequests protected $redis; /** - * The timestamp of the end of the current duration. + * The timestamp of the end of the current duration by key. * - * @var int + * @var array */ - public $decaysAt; + public $decaysAt = []; /** - * The number of remaining slots. + * The number of remaining slots by key. * - * @var int + * @var array */ - public $remaining; + public $remaining = []; /** * Create a new request throttler. * + * @param \Illuminate\Cache\RateLimiter $limiter * @param \Illuminate\Contracts\Redis\Factory $redis * @return void */ - public function __construct(Redis $redis) + public function __construct(RateLimiter $limiter, Redis $redis) { + parent::__construct($limiter); + $this->redis = $redis; } @@ -45,29 +49,30 @@ class ThrottleRequestsWithRedis extends ThrottleRequests * * @param \Illuminate\Http\Request $request * @param \Closure $next - * @param int|string $maxAttempts - * @param float|int $decayMinutes - * @param string $prefix - * @return mixed + * @param array $limits + * @return \Symfony\Component\HttpFoundation\Response * - * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException */ - public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '') + protected function handleRequest($request, Closure $next, array $limits) { - $key = $prefix.$this->resolveRequestSignature($request); - - $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts); - - if ($this->tooManyAttempts($key, $maxAttempts, $decayMinutes)) { - throw $this->buildException($key, $maxAttempts); + foreach ($limits as $limit) { + if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decayMinutes)) { + throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); + } } $response = $next($request); - return $this->addHeaders( - $response, $maxAttempts, - $this->calculateRemainingAttempts($key, $maxAttempts) - ); + foreach ($limits as $limit) { + $response = $this->addHeaders( + $response, + $limit->maxAttempts, + $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts) + ); + } + + return $response; } /** @@ -84,8 +89,8 @@ class ThrottleRequestsWithRedis extends ThrottleRequests $this->redis, $key, $maxAttempts, $decayMinutes * 60 ); - return tap(! $limiter->acquire(), function () use ($limiter) { - [$this->decaysAt, $this->remaining] = [ + return tap(! $limiter->acquire(), function () use ($key, $limiter) { + [$this->decaysAt[$key], $this->remaining[$key]] = [ $limiter->decaysAt, $limiter->remaining, ]; }); @@ -101,11 +106,7 @@ class ThrottleRequestsWithRedis extends ThrottleRequests */ protected function calculateRemainingAttempts($key, $maxAttempts, $retryAfter = null) { - if (is_null($retryAfter)) { - return $this->remaining; - } - - return 0; + return is_null($retryAfter) ? $this->remaining[$key] : 0; } /** @@ -116,6 +117,6 @@ class ThrottleRequestsWithRedis extends ThrottleRequests */ protected function getTimeUntilNextRetry($key) { - return $this->decaysAt - $this->currentTime(); + return $this->decaysAt[$key] - $this->currentTime(); } } diff --git a/src/Illuminate/Routing/Middleware/ValidateSignature.php b/src/Illuminate/Routing/Middleware/ValidateSignature.php index 85de9a24aab461346d76b2c65d7e3796c1999ecb..59fd368ca058e037668e61bbf5df89b5f92c7cb0 100644 --- a/src/Illuminate/Routing/Middleware/ValidateSignature.php +++ b/src/Illuminate/Routing/Middleware/ValidateSignature.php @@ -12,13 +12,14 @@ class ValidateSignature * * @param \Illuminate\Http\Request $request * @param \Closure $next + * @param string|null $relative * @return \Illuminate\Http\Response * * @throws \Illuminate\Routing\Exceptions\InvalidSignatureException */ - public function handle($request, Closure $next) + public function handle($request, Closure $next, $relative = null) { - if ($request->hasValidSignature()) { + if ($request->hasValidSignature($relative !== 'relative')) { return $next($request); } diff --git a/src/Illuminate/Routing/PendingResourceRegistration.php b/src/Illuminate/Routing/PendingResourceRegistration.php index b7d158ddd7b2edc81380417ef0453b413dd783c2..5e4890a07b7d18091bbb19656fed495d864bff0a 100644 --- a/src/Illuminate/Routing/PendingResourceRegistration.php +++ b/src/Illuminate/Routing/PendingResourceRegistration.php @@ -2,11 +2,12 @@ namespace Illuminate\Routing; +use Illuminate\Support\Arr; use Illuminate\Support\Traits\Macroable; class PendingResourceRegistration { - use Macroable; + use CreatesRegularExpressionRouteConstraints, Macroable; /** * The resource registrar. @@ -148,11 +149,45 @@ class PendingResourceRegistration */ public function middleware($middleware) { + $middleware = Arr::wrap($middleware); + + foreach ($middleware as $key => $value) { + $middleware[$key] = (string) $value; + } + $this->options['middleware'] = $middleware; return $this; } + /** + * Specify middleware that should be removed from the resource routes. + * + * @param array|string $middleware + * @return $this|array + */ + public function withoutMiddleware($middleware) + { + $this->options['excluded_middleware'] = array_merge( + (array) ($this->options['excluded_middleware'] ?? []), Arr::wrap($middleware) + ); + + return $this; + } + + /** + * Add "where" constraints to the resource routes. + * + * @param mixed $wheres + * @return \Illuminate\Routing\PendingResourceRegistration + */ + public function where($wheres) + { + $this->options['wheres'] = $wheres; + + return $this; + } + /** * Indicate that the resource routes should have "shallow" nesting. * @@ -166,6 +201,32 @@ class PendingResourceRegistration return $this; } + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param callable $callback + * @return $this + */ + public function missing($callback) + { + $this->options['missing'] = $callback; + + return $this; + } + + /** + * Indicate that the resource routes should be scoped using the given binding fields. + * + * @param array $fields + * @return \Illuminate\Routing\PendingResourceRegistration + */ + public function scoped(array $fields = []) + { + $this->options['bindingFields'] = $fields; + + return $this; + } + /** * Register the resource route. * diff --git a/src/Illuminate/Routing/Pipeline.php b/src/Illuminate/Routing/Pipeline.php index 3d4a684cf39aaab73502fe2a72d8be481708d470..e43d59199026ccd4f2b0cd13f03e0714a759b983 100644 --- a/src/Illuminate/Routing/Pipeline.php +++ b/src/Illuminate/Routing/Pipeline.php @@ -2,11 +2,11 @@ namespace Illuminate\Routing; -use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Support\Responsable; use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline as BasePipeline; +use Throwable; /** * This extended pipeline catches any exceptions that occur during each slice. @@ -32,12 +32,12 @@ class Pipeline extends BasePipeline * Handle the given exception. * * @param mixed $passable - * @param \Exception $e + * @param \Throwable $e * @return mixed * - * @throws \Exception + * @throws \Throwable */ - protected function handleException($passable, Exception $e) + protected function handleException($passable, Throwable $e) { if (! $this->container->bound(ExceptionHandler::class) || ! $passable instanceof Request) { diff --git a/src/Illuminate/Routing/Redirector.php b/src/Illuminate/Routing/Redirector.php index e522f51931dc783229e768c384e0f8013c5a4bab..99e7c226985d025f2ee480f8a04095db52315ff5 100755 --- a/src/Illuminate/Routing/Redirector.php +++ b/src/Illuminate/Routing/Redirector.php @@ -176,6 +176,36 @@ class Redirector return $this->to($this->generator->route($route, $parameters), $status, $headers); } + /** + * Create a new redirect response to a signed named route. + * + * @param string $route + * @param mixed $parameters + * @param \DateTimeInterface|\DateInterval|int|null $expiration + * @param int $status + * @param array $headers + * @return \Illuminate\Http\RedirectResponse + */ + public function signedRoute($route, $parameters = [], $expiration = null, $status = 302, $headers = []) + { + return $this->to($this->generator->signedRoute($route, $parameters, $expiration), $status, $headers); + } + + /** + * Create a new redirect response to a signed named route. + * + * @param string $route + * @param \DateTimeInterface|\DateInterval|int|null $expiration + * @param mixed $parameters + * @param int $status + * @param array $headers + * @return \Illuminate\Http\RedirectResponse + */ + public function temporarySignedRoute($route, $expiration, $parameters = [], $status = 302, $headers = []) + { + return $this->to($this->generator->temporarySignedRoute($route, $expiration, $parameters), $status, $headers); + } + /** * Create a new redirect response to a controller action. * diff --git a/src/Illuminate/Routing/ResourceRegistrar.php b/src/Illuminate/Routing/ResourceRegistrar.php index f9353da035e74cd7415ba3df9ff74c79b0dcb13b..c32de58c291f249bafb2c9cb5e0cc053cb324319 100644 --- a/src/Illuminate/Routing/ResourceRegistrar.php +++ b/src/Illuminate/Routing/ResourceRegistrar.php @@ -16,7 +16,7 @@ class ResourceRegistrar /** * The default actions for a resourceful controller. * - * @var array + * @var string[] */ protected $resourceDefaults = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']; @@ -95,9 +95,15 @@ class ResourceRegistrar $collection = new RouteCollection; foreach ($this->getResourceMethods($defaults, $options) as $m) { - $collection->add($this->{'addResource'.ucfirst($m)}( + $route = $this->{'addResource'.ucfirst($m)}( $name, $base, $controller, $options - )); + ); + + if (isset($options['bindingFields'])) { + $this->setResourceBindingFields($route, $options['bindingFields']); + } + + $collection->add($route); } return $collection; @@ -178,6 +184,8 @@ class ResourceRegistrar { $uri = $this->getResourceUri($name); + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'index', $options); return $this->router->get($uri, $action); @@ -196,6 +204,8 @@ class ResourceRegistrar { $uri = $this->getResourceUri($name).'/'.static::$verbs['create']; + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'create', $options); return $this->router->get($uri, $action); @@ -214,6 +224,8 @@ class ResourceRegistrar { $uri = $this->getResourceUri($name); + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'store', $options); return $this->router->post($uri, $action); @@ -313,6 +325,24 @@ class ResourceRegistrar : $name; } + /** + * Set the route's binding fields if the resource is scoped. + * + * @param \Illuminate\Routing\Route $route + * @param array $bindingFields + * @return void + */ + protected function setResourceBindingFields($route, $bindingFields) + { + preg_match_all('/(?<={).*?(?=})/', $route->uri, $matches); + + $fields = array_fill_keys($matches[0], null); + + $route->setBindingFields(array_replace( + $fields, array_intersect_key($bindingFields, $fields) + )); + } + /** * Get the base resource URI for a given resource. * @@ -389,6 +419,18 @@ class ResourceRegistrar $action['middleware'] = $options['middleware']; } + if (isset($options['excluded_middleware'])) { + $action['excluded_middleware'] = $options['excluded_middleware']; + } + + if (isset($options['wheres'])) { + $action['where'] = $options['wheres']; + } + + if (isset($options['missing'])) { + $action['missing'] = $options['missing']; + } + return $action; } diff --git a/src/Illuminate/Routing/ResponseFactory.php b/src/Illuminate/Routing/ResponseFactory.php index fa844774130e6cb545086fbe54675b470726204e..8a914f8dd03155f71166dfee53cfcf975f3e31a8 100644 --- a/src/Illuminate/Routing/ResponseFactory.php +++ b/src/Illuminate/Routing/ResponseFactory.php @@ -45,7 +45,7 @@ class ResponseFactory implements FactoryContract /** * Create a new response instance. * - * @param string $content + * @param mixed $content * @param int $status * @param array $headers * @return \Illuminate\Http\Response @@ -212,7 +212,7 @@ class ResponseFactory implements FactoryContract * Create a new redirect response to a named route. * * @param string $route - * @param array $parameters + * @param mixed $parameters * @param int $status * @param array $headers * @return \Illuminate\Http\RedirectResponse @@ -226,7 +226,7 @@ class ResponseFactory implements FactoryContract * Create a new redirect response to a controller action. * * @param string $action - * @param array $parameters + * @param mixed $parameters * @param int $status * @param array $headers * @return \Illuminate\Http\RedirectResponse diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index 9b31e619fea955b1d471883375d95a6ec24619db..95e3cd32d0bd91b2ee2f5b609b7f83dfb987eb94 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -14,12 +14,15 @@ use Illuminate\Routing\Matching\UriValidator; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Laravel\SerializableClosure\SerializableClosure; use LogicException; +use Opis\Closure\SerializableClosure as OpisSerializableClosure; use ReflectionFunction; +use Symfony\Component\Routing\Route as SymfonyRoute; class Route { - use Macroable, RouteDependencyResolverTrait; + use CreatesRegularExpressionRouteConstraints, Macroable, RouteDependencyResolverTrait; /** * The URI pattern the route responds to. @@ -91,6 +94,27 @@ class Route */ protected $originalParameters; + /** + * Indicates "trashed" models can be retrieved when resolving implicit model bindings for this route. + * + * @var bool + */ + protected $withTrashedBindings = false; + + /** + * Indicates the maximum number of seconds the route should acquire a session lock for. + * + * @var int|null + */ + protected $lockSeconds; + + /** + * Indicates the maximum number of seconds the route should wait while attempting to acquire a session lock. + * + * @var int|null + */ + protected $waitSeconds; + /** * The computed gathered middleware. * @@ -119,6 +143,13 @@ class Route */ protected $container; + /** + * The fields that implicit binding should use for a given parameter. + * + * @var array + */ + protected $bindingFields = []; + /** * The validators used by the routes. * @@ -138,15 +169,13 @@ class Route { $this->uri = $uri; $this->methods = (array) $methods; - $this->action = $this->parseAction($action); + $this->action = Arr::except($this->parseAction($action), ['prefix']); if (in_array('GET', $this->methods) && ! in_array('HEAD', $this->methods)) { $this->methods[] = 'HEAD'; } - if (isset($this->action['prefix'])) { - $this->prefix($this->action['prefix']); - } + $this->prefix(is_array($action) ? Arr::get($action, 'prefix') : ''); } /** @@ -189,7 +218,7 @@ class Route */ protected function isControllerAction() { - return is_string($this->action['uses']); + return is_string($this->action['uses']) && ! $this->isSerializedClosure(); } /** @@ -201,11 +230,25 @@ class Route { $callable = $this->action['uses']; + if ($this->isSerializedClosure()) { + $callable = unserialize($this->action['uses'])->getClosure(); + } + return $callable(...array_values($this->resolveMethodDependencies( - $this->parametersWithoutNulls(), new ReflectionFunction($this->action['uses']) + $this->parametersWithoutNulls(), new ReflectionFunction($callable) ))); } + /** + * Determine if the route action is a serialized Closure. + * + * @return bool + */ + protected function isSerializedClosure() + { + return RouteAction::containsSerializedClosure($this->action); + } + /** * Run the route action and return the response. * @@ -257,7 +300,18 @@ class Route } /** - * Determine if the route matches given request. + * Flush the cached container instance on the route. + * + * @return void + */ + public function flushController() + { + $this->computedMiddleware = null; + $this->controller = null; + } + + /** + * Determine if the route matches a given request. * * @param \Illuminate\Http\Request $request * @param bool $includingMethod @@ -267,7 +321,7 @@ class Route { $this->compileRoute(); - foreach ($this->getValidators() as $validator) { + foreach (self::getValidators() as $validator) { if (! $includingMethod && $validator instanceof MethodValidator) { continue; } @@ -288,7 +342,7 @@ class Route protected function compileRoute() { if (! $this->compiled) { - $this->compiled = (new RouteCompiler($this))->compile(); + $this->compiled = $this->toSymfonyRoute()->compile(); } return $this->compiled; @@ -341,8 +395,8 @@ class Route * Get a given parameter from the route. * * @param string $name - * @param mixed $default - * @return string|object + * @param string|object|null $default + * @return string|object|null */ public function parameter($name, $default = null) { @@ -353,8 +407,8 @@ class Route * Get original value of a given parameter from the route. * * @param string $name - * @param mixed $default - * @return string + * @param string|null $default + * @return string|null */ public function originalParameter($name, $default = null) { @@ -365,7 +419,7 @@ class Route * Set a parameter to the given value. * * @param string $name - * @param mixed $value + * @param string|object|null $value * @return void */ public function setParameter($name, $value) @@ -471,6 +525,82 @@ class Route return RouteSignatureParameters::fromAction($this->action, $subClass); } + /** + * Get the binding field for the given parameter. + * + * @param string|int $parameter + * @return string|null + */ + public function bindingFieldFor($parameter) + { + $fields = is_int($parameter) ? array_values($this->bindingFields) : $this->bindingFields; + + return $fields[$parameter] ?? null; + } + + /** + * Get the binding fields for the route. + * + * @return array + */ + public function bindingFields() + { + return $this->bindingFields ?? []; + } + + /** + * Set the binding fields for the route. + * + * @param array $bindingFields + * @return $this + */ + public function setBindingFields(array $bindingFields) + { + $this->bindingFields = $bindingFields; + + return $this; + } + + /** + * Get the parent parameter of the given parameter. + * + * @param string $parameter + * @return string + */ + public function parentOfParameter($parameter) + { + $key = array_search($parameter, array_keys($this->parameters)); + + if ($key === 0) { + return; + } + + return array_values($this->parameters)[$key - 1]; + } + + /** + * Allow "trashed" models to be retrieved when resolving implicit model bindings for this route. + * + * @param bool $withTrashed + * @return $this + */ + public function withTrashed($withTrashed = true) + { + $this->withTrashedBindings = $withTrashed; + + return $this; + } + + /** + * Determines if the route allows "trashed" models to be retrieved when resolving implicit model bindings. + * + * @return bool + */ + public function allowsTrashedBindings() + { + return $this->withTrashedBindings; + } + /** * Set a default value for the route. * @@ -485,6 +615,19 @@ class Route return $this; } + /** + * Set the default values for the route. + * + * @param array $defaults + * @return $this + */ + public function setDefaults(array $defaults) + { + $this->defaults = $defaults; + + return $this; + } + /** * Set a regular expression requirement on the route. * @@ -519,7 +662,7 @@ class Route * @param array $wheres * @return $this */ - protected function whereArray(array $wheres) + public function setWheres(array $wheres) { foreach ($wheres as $name => $expression) { $this->where($name, $expression); @@ -540,6 +683,19 @@ class Route return $this; } + /** + * Set the fallback value. + * + * @param bool $isFallback + * @return $this + */ + public function setFallback($isFallback) + { + $this->isFallback = $isFallback; + + return $this; + } + /** * Get the HTTP verbs the route responds to. * @@ -592,7 +748,13 @@ class Route return $this->getDomain(); } - $this->action['domain'] = $domain; + $parsed = RouteUri::parse($domain); + + $this->action['domain'] = $parsed->uri; + + $this->bindingFields = array_merge( + $this->bindingFields, $parsed->bindingFields + ); return $this; } @@ -611,7 +773,7 @@ class Route /** * Get the prefix of the route instance. * - * @return string + * @return string|null */ public function getPrefix() { @@ -626,11 +788,26 @@ class Route */ public function prefix($prefix) { + $prefix = $prefix ?? ''; + + $this->updatePrefixOnAction($prefix); + $uri = rtrim($prefix, '/').'/'.ltrim($this->uri, '/'); - $this->uri = trim($uri, '/'); + return $this->setUri($uri !== '/' ? trim($uri, '/') : $uri); + } - return $this; + /** + * Update the "prefix" attribute on the action array. + * + * @param string $prefix + * @return void + */ + protected function updatePrefixOnAction($prefix) + { + if (! empty($newPrefix = trim(rtrim($prefix, '/').'/'.ltrim($this->action['prefix'] ?? '', '/'), '/'))) { + $this->action['prefix'] = $newPrefix; + } } /** @@ -651,16 +828,31 @@ class Route */ public function setUri($uri) { - $this->uri = $uri; + $this->uri = $this->parseUri($uri); return $this; } /** - * Get the name of the route instance. + * Parse the route URI and normalize / store any implicit binding fields. * + * @param string $uri * @return string */ + protected function parseUri($uri) + { + $this->bindingFields = []; + + return tap(RouteUri::parse($uri), function ($uri) { + $this->bindingFields = $uri->bindingFields; + })->uri; + } + + /** + * Get the name of the route instance. + * + * @return string|null + */ public function getName() { return $this->action['as'] ?? null; @@ -703,11 +895,15 @@ class Route /** * Set the handler for the route. * - * @param \Closure|string $action + * @param \Closure|array|string $action * @return $this */ public function uses($action) { + if (is_array($action)) { + $action = $action[0].'@'.$action[1]; + } + $action = is_string($action) ? $this->addGroupNamespaceToStringUses($action) : $action; return $this->setAction(array_merge($this->action, $this->parseAction([ @@ -774,6 +970,39 @@ class Route { $this->action = $action; + if (isset($this->action['domain'])) { + $this->domain($this->action['domain']); + } + + return $this; + } + + /** + * Get the value of the action that should be taken on a missing model exception. + * + * @return \Closure|null + */ + public function getMissing() + { + $missing = $this->action['missing'] ?? null; + + return is_string($missing) && + Str::startsWith($missing, [ + 'C:32:"Opis\\Closure\\SerializableClosure', + 'O:47:"Laravel\\SerializableClosure\\SerializableClosure', + ]) ? unserialize($missing) : $missing; + } + + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param \Closure $missing + * @return $this + */ + public function missing($missing) + { + $this->action['missing'] = $missing; + return $this; } @@ -807,10 +1036,14 @@ class Route return (array) ($this->action['middleware'] ?? []); } - if (is_string($middleware)) { + if (! is_array($middleware)) { $middleware = func_get_args(); } + foreach ($middleware as $index => $value) { + $middleware[$index] = (string) $value; + } + $this->action['middleware'] = array_merge( (array) ($this->action['middleware'] ?? []), $middleware ); @@ -818,6 +1051,20 @@ class Route return $this; } + /** + * Specify that the "Authorize" / "can" middleware should be applied to the route with the given options. + * + * @param string $ability + * @param array|string $models + * @return $this + */ + public function can($ability, $models = []) + { + return empty($models) + ? $this->middleware(['can:'.$ability]) + : $this->middleware(['can:'.$ability.','.implode(',', Arr::wrap($models))]); + } + /** * Get the middleware for the route's controller. * @@ -834,6 +1081,98 @@ class Route ); } + /** + * Specify middleware that should be removed from the given route. + * + * @param array|string $middleware + * @return $this|array + */ + public function withoutMiddleware($middleware) + { + $this->action['excluded_middleware'] = array_merge( + (array) ($this->action['excluded_middleware'] ?? []), Arr::wrap($middleware) + ); + + return $this; + } + + /** + * Get the middleware should be removed from the route. + * + * @return array + */ + public function excludedMiddleware() + { + return (array) ($this->action['excluded_middleware'] ?? []); + } + + /** + * Indicate that the route should enforce scoping of multiple implicit Eloquent bindings. + * + * @return bool + */ + public function scopeBindings() + { + $this->action['scope_bindings'] = true; + + return $this; + } + + /** + * Determine if the route should enforce scoping of multiple implicit Eloquent bindings. + * + * @return bool + */ + public function enforcesScopedBindings() + { + return (bool) ($this->action['scope_bindings'] ?? false); + } + + /** + * Specify that the route should not allow concurrent requests from the same session. + * + * @param int|null $lockSeconds + * @param int|null $waitSeconds + * @return $this + */ + public function block($lockSeconds = 10, $waitSeconds = 10) + { + $this->lockSeconds = $lockSeconds; + $this->waitSeconds = $waitSeconds; + + return $this; + } + + /** + * Specify that the route should allow concurrent requests from the same session. + * + * @return $this + */ + public function withoutBlocking() + { + return $this->block(null, null); + } + + /** + * Get the maximum number of seconds the route's session lock should be held for. + * + * @return int|null + */ + public function locksFor() + { + return $this->lockSeconds; + } + + /** + * Get the maximum number of seconds to wait while attempting to acquire a session lock. + * + * @return int|null + */ + public function waitsFor() + { + return $this->waitSeconds; + } + /** * Get the dispatcher for the route's controller. * @@ -868,6 +1207,32 @@ class Route ]; } + /** + * Convert the route to a Symfony route. + * + * @return \Symfony\Component\Routing\Route + */ + public function toSymfonyRoute() + { + return new SymfonyRoute( + preg_replace('/\{(\w+?)\?\}/', '{$1}', $this->uri()), $this->getOptionalParameterNames(), + $this->wheres, ['utf8' => true, 'action' => $this->action], + $this->getDomain() ?: '', [], $this->methods + ); + } + + /** + * Get the optional parameter names for the route. + * + * @return array + */ + protected function getOptionalParameterNames() + { + preg_match_all('/\{(\w+?)\?\}/', $this->uri(), $matches); + + return isset($matches[1]) ? array_fill_keys($matches[1], null) : []; + } + /** * Get the compiled version of the route. * @@ -914,7 +1279,17 @@ class Route public function prepareForSerialization() { if ($this->action['uses'] instanceof Closure) { - throw new LogicException("Unable to prepare route [{$this->uri}] for serialization. Uses Closure."); + $this->action['uses'] = serialize(\PHP_VERSION_ID < 70400 + ? new OpisSerializableClosure($this->action['uses']) + : new SerializableClosure($this->action['uses']) + ); + } + + if (isset($this->action['missing']) && $this->action['missing'] instanceof Closure) { + $this->action['missing'] = serialize(\PHP_VERSION_ID < 70400 + ? new OpisSerializableClosure($this->action['missing']) + : new SerializableClosure($this->action['missing']) + ); } $this->compileRoute(); diff --git a/src/Illuminate/Routing/RouteAction.php b/src/Illuminate/Routing/RouteAction.php index 9d7eb76a85d889c2554e8be7d96524d5281fda28..b356f974cc99370b99ae3a195533156175e63ed8 100644 --- a/src/Illuminate/Routing/RouteAction.php +++ b/src/Illuminate/Routing/RouteAction.php @@ -43,7 +43,7 @@ class RouteAction $action['uses'] = static::findCallable($action); } - if (is_string($action['uses']) && ! Str::contains($action['uses'], '@')) { + if (! static::containsSerializedClosure($action) && is_string($action['uses']) && ! Str::contains($action['uses'], '@')) { $action['uses'] = static::makeInvokable($action['uses']); } @@ -94,4 +94,18 @@ class RouteAction return $action.'@__invoke'; } + + /** + * Determine if the given array actions contain a serialized Closure. + * + * @param array $action + * @return bool + */ + public static function containsSerializedClosure(array $action) + { + return is_string($action['uses']) && Str::startsWith($action['uses'], [ + 'C:32:"Opis\\Closure\\SerializableClosure', + 'O:47:"Laravel\\SerializableClosure\\SerializableClosure', + ]) !== false; + } } diff --git a/src/Illuminate/Routing/RouteCollection.php b/src/Illuminate/Routing/RouteCollection.php index 08aa4464e3616752a8cde6d8b76ebc8404c88542..7e6f98bca0556b2b2e01ef1f38a84cb1b83e997b 100644 --- a/src/Illuminate/Routing/RouteCollection.php +++ b/src/Illuminate/Routing/RouteCollection.php @@ -2,16 +2,11 @@ namespace Illuminate\Routing; -use ArrayIterator; -use Countable; +use Illuminate\Container\Container; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Support\Arr; -use IteratorAggregate; -use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -class RouteCollection implements Countable, IteratorAggregate +class RouteCollection extends AbstractRouteCollection { /** * An array of the routes keyed by method. @@ -23,21 +18,21 @@ class RouteCollection implements Countable, IteratorAggregate /** * A flattened array of all of the routes. * - * @var array + * @var \Illuminate\Routing\Route[] */ protected $allRoutes = []; /** * A look-up table of routes by their names. * - * @var array + * @var \Illuminate\Routing\Route[] */ protected $nameList = []; /** * A look-up table of routes by controller action. * - * @var array + * @var \Illuminate\Routing\Route[] */ protected $actionList = []; @@ -152,6 +147,7 @@ class RouteCollection implements Countable, IteratorAggregate * @param \Illuminate\Http\Request $request * @return \Illuminate\Routing\Route * + * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException */ public function match(Request $request) @@ -163,111 +159,14 @@ class RouteCollection implements Countable, IteratorAggregate // by the consumer. Otherwise we will check for routes with another verb. $route = $this->matchAgainstRoutes($routes, $request); - if (! is_null($route)) { - return $route->bind($request); - } - - // If no route was found we will now check if a matching route is specified by - // another HTTP verb. If it is we will need to throw a MethodNotAllowed and - // inform the user agent of which HTTP verb it should use for this route. - $others = $this->checkForAlternateVerbs($request); - - if (count($others) > 0) { - return $this->getRouteForMethods($request, $others); - } - - throw new NotFoundHttpException; - } - - /** - * Determine if a route in the array matches the request. - * - * @param array $routes - * @param \Illuminate\Http\Request $request - * @param bool $includingMethod - * @return \Illuminate\Routing\Route|null - */ - protected function matchAgainstRoutes(array $routes, $request, $includingMethod = true) - { - [$fallbacks, $routes] = collect($routes)->partition(function ($route) { - return $route->isFallback; - }); - - return $routes->merge($fallbacks)->first(function ($value) use ($request, $includingMethod) { - return $value->matches($request, $includingMethod); - }); - } - - /** - * Determine if any routes match on another HTTP verb. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - protected function checkForAlternateVerbs($request) - { - $methods = array_diff(Router::$verbs, [$request->getMethod()]); - - // Here we will spin through all verbs except for the current request verb and - // check to see if any routes respond to them. If they do, we will return a - // proper error response with the correct headers on the response string. - $others = []; - - foreach ($methods as $method) { - if (! is_null($this->matchAgainstRoutes($this->get($method), $request, false))) { - $others[] = $method; - } - } - - return $others; - } - - /** - * Get a route (if necessary) that responds when other available methods are present. - * - * @param \Illuminate\Http\Request $request - * @param array $methods - * @return \Illuminate\Routing\Route - * - * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException - */ - protected function getRouteForMethods($request, array $methods) - { - if ($request->method() === 'OPTIONS') { - return (new Route('OPTIONS', $request->path(), function () use ($methods) { - return new Response('', 200, ['Allow' => implode(',', $methods)]); - }))->bind($request); - } - - $this->methodNotAllowed($methods, $request->method()); - } - - /** - * Throw a method not allowed HTTP exception. - * - * @param array $others - * @param string $method - * @return void - * - * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException - */ - protected function methodNotAllowed(array $others, $method) - { - throw new MethodNotAllowedHttpException( - $others, - sprintf( - 'The %s method is not supported for this route. Supported methods: %s.', - $method, - implode(', ', $others) - ) - ); + return $this->handleMatchedRoute($request, $route); } /** * Get routes from the collection by method. * * @param string|null $method - * @return array + * @return \Illuminate\Routing\Route[] */ public function get($method = null) { @@ -310,7 +209,7 @@ class RouteCollection implements Countable, IteratorAggregate /** * Get all of the routes in the collection. * - * @return array + * @return \Illuminate\Routing\Route[] */ public function getRoutes() { @@ -330,7 +229,7 @@ class RouteCollection implements Countable, IteratorAggregate /** * Get all of the routes keyed by their name. * - * @return array + * @return \Illuminate\Routing\Route[] */ public function getRoutesByName() { @@ -338,22 +237,32 @@ class RouteCollection implements Countable, IteratorAggregate } /** - * Get an iterator for the items. + * Convert the collection to a Symfony RouteCollection instance. * - * @return \ArrayIterator + * @return \Symfony\Component\Routing\RouteCollection */ - public function getIterator() + public function toSymfonyRouteCollection() { - return new ArrayIterator($this->getRoutes()); + $symfonyRoutes = parent::toSymfonyRouteCollection(); + + $this->refreshNameLookups(); + + return $symfonyRoutes; } /** - * Count the number of items in the collection. + * Convert the collection to a CompiledRouteCollection instance. * - * @return int + * @param \Illuminate\Routing\Router $router + * @param \Illuminate\Container\Container $container + * @return \Illuminate\Routing\CompiledRouteCollection */ - public function count() + public function toCompiledRouteCollection(Router $router, Container $container) { - return count($this->getRoutes()); + ['compiled' => $compiled, 'attributes' => $attributes] = $this->compile(); + + return (new CompiledRouteCollection($compiled, $attributes)) + ->setRouter($router) + ->setContainer($container); } } diff --git a/src/Illuminate/Routing/RouteCollectionInterface.php b/src/Illuminate/Routing/RouteCollectionInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..8e25d0fa105681bb4b155f2c2292dc12fe3d6de8 --- /dev/null +++ b/src/Illuminate/Routing/RouteCollectionInterface.php @@ -0,0 +1,98 @@ +<?php + +namespace Illuminate\Routing; + +use Illuminate\Http\Request; + +interface RouteCollectionInterface +{ + /** + * Add a Route instance to the collection. + * + * @param \Illuminate\Routing\Route $route + * @return \Illuminate\Routing\Route + */ + public function add(Route $route); + + /** + * Refresh the name look-up table. + * + * This is done in case any names are fluently defined or if routes are overwritten. + * + * @return void + */ + public function refreshNameLookups(); + + /** + * Refresh the action look-up table. + * + * This is done in case any actions are overwritten with new controllers. + * + * @return void + */ + public function refreshActionLookups(); + + /** + * Find the first route matching a given request. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Routing\Route + * + * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public function match(Request $request); + + /** + * Get routes from the collection by method. + * + * @param string|null $method + * @return \Illuminate\Routing\Route[] + */ + public function get($method = null); + + /** + * Determine if the route collection contains a given named route. + * + * @param string $name + * @return bool + */ + public function hasNamedRoute($name); + + /** + * Get a route instance by its name. + * + * @param string $name + * @return \Illuminate\Routing\Route|null + */ + public function getByName($name); + + /** + * Get a route instance by its controller action. + * + * @param string $action + * @return \Illuminate\Routing\Route|null + */ + public function getByAction($action); + + /** + * Get all of the routes in the collection. + * + * @return \Illuminate\Routing\Route[] + */ + public function getRoutes(); + + /** + * Get all of the routes keyed by their HTTP verb / method. + * + * @return array + */ + public function getRoutesByMethod(); + + /** + * Get all of the routes keyed by their name. + * + * @return \Illuminate\Routing\Route[] + */ + public function getRoutesByName(); +} diff --git a/src/Illuminate/Routing/RouteCompiler.php b/src/Illuminate/Routing/RouteCompiler.php deleted file mode 100644 index c191663bc3dbed6e3b44543d6f6be4a48bb1506b..0000000000000000000000000000000000000000 --- a/src/Illuminate/Routing/RouteCompiler.php +++ /dev/null @@ -1,54 +0,0 @@ -<?php - -namespace Illuminate\Routing; - -use Symfony\Component\Routing\Route as SymfonyRoute; - -class RouteCompiler -{ - /** - * The route instance. - * - * @var \Illuminate\Routing\Route - */ - protected $route; - - /** - * Create a new Route compiler instance. - * - * @param \Illuminate\Routing\Route $route - * @return void - */ - public function __construct($route) - { - $this->route = $route; - } - - /** - * Compile the route. - * - * @return \Symfony\Component\Routing\CompiledRoute - */ - public function compile() - { - $optionals = $this->getOptionalParameters(); - - $uri = preg_replace('/\{(\w+?)\?\}/', '{$1}', $this->route->uri()); - - return ( - new SymfonyRoute($uri, $optionals, $this->route->wheres, ['utf8' => true], $this->route->getDomain() ?: '') - )->compile(); - } - - /** - * Get the optional parameters for the route. - * - * @return array - */ - protected function getOptionalParameters() - { - preg_match_all('/\{(\w+?)\?\}/', $this->route->uri(), $matches); - - return isset($matches[1]) ? array_fill_keys($matches[1], null) : []; - } -} diff --git a/src/Illuminate/Routing/RouteDependencyResolverTrait.php b/src/Illuminate/Routing/RouteDependencyResolverTrait.php index b3e887b169cb700f9c3927058851822b58a4f119..122f266c84b60536636353aed8fe46a926b0d1b3 100644 --- a/src/Illuminate/Routing/RouteDependencyResolverTrait.php +++ b/src/Illuminate/Routing/RouteDependencyResolverTrait.php @@ -42,12 +42,12 @@ trait RouteDependencyResolverTrait $values = array_values($parameters); + $skippableValue = new \stdClass; + foreach ($reflector->getParameters() as $key => $parameter) { - $instance = $this->transformDependency( - $parameter, $parameters - ); + $instance = $this->transformDependency($parameter, $parameters, $skippableValue); - if (! is_null($instance)) { + if ($instance !== $skippableValue) { $instanceCount++; $this->spliceIntoParameters($parameters, $key, $instance); @@ -65,9 +65,10 @@ trait RouteDependencyResolverTrait * * @param \ReflectionParameter $parameter * @param array $parameters + * @param object $skippableValue * @return mixed */ - protected function transformDependency(ReflectionParameter $parameter, $parameters) + protected function transformDependency(ReflectionParameter $parameter, $parameters, $skippableValue) { $className = Reflector::getParameterClassName($parameter); @@ -75,10 +76,10 @@ trait RouteDependencyResolverTrait // the list of parameters. If it is we will just skip it as it is probably a model // binding and we do not want to mess with those; otherwise, we resolve it here. if ($className && ! $this->alreadyInParameters($className, $parameters)) { - return $parameter->isDefaultValueAvailable() - ? $parameter->getDefaultValue() - : $this->container->make($className); + return $parameter->isDefaultValueAvailable() ? null : $this->container->make($className); } + + return $skippableValue; } /** diff --git a/src/Illuminate/Routing/RouteGroup.php b/src/Illuminate/Routing/RouteGroup.php index 4041f1f79b01a8f3f1c09f19d3d91bd4fc4c93d9..18dbb5245b8f4f61cefa708c2685346116123502 100644 --- a/src/Illuminate/Routing/RouteGroup.php +++ b/src/Illuminate/Routing/RouteGroup.php @@ -11,17 +11,22 @@ class RouteGroup * * @param array $new * @param array $old + * @param bool $prependExistingPrefix * @return array */ - public static function merge($new, $old) + public static function merge($new, $old, $prependExistingPrefix = true) { if (isset($new['domain'])) { unset($old['domain']); } + if (isset($new['controller'])) { + unset($old['controller']); + } + $new = array_merge(static::formatAs($new, $old), [ 'namespace' => static::formatNamespace($new, $old), - 'prefix' => static::formatPrefix($new, $old), + 'prefix' => static::formatPrefix($new, $old, $prependExistingPrefix), 'where' => static::formatWhere($new, $old), ]); @@ -53,13 +58,18 @@ class RouteGroup * * @param array $new * @param array $old + * @param bool $prependExistingPrefix * @return string|null */ - protected static function formatPrefix($new, $old) + protected static function formatPrefix($new, $old, $prependExistingPrefix = true) { - $old = $old['prefix'] ?? null; + $old = $old['prefix'] ?? ''; - return isset($new['prefix']) ? trim($old, '/').'/'.trim($new['prefix'], '/') : $old; + if ($prependExistingPrefix) { + return isset($new['prefix']) ? trim($old, '/').'/'.trim($new['prefix'], '/') : $old; + } else { + return isset($new['prefix']) ? trim($new['prefix'], '/').'/'.trim($old, '/') : $old; + } } /** diff --git a/src/Illuminate/Routing/RouteParameterBinder.php b/src/Illuminate/Routing/RouteParameterBinder.php index 53e766efcfa2c2230a9a27857aa73da4e55946ce..8c3968e0f828bb7a9929f0ed26d61ee679276d40 100644 --- a/src/Illuminate/Routing/RouteParameterBinder.php +++ b/src/Illuminate/Routing/RouteParameterBinder.php @@ -32,9 +32,6 @@ class RouteParameterBinder */ public function parameters($request) { - // If the route has a regular expression for the host part of the URI, we will - // compile that and get the parameter matches for this domain. We will then - // merge them into this parameters array so that this array is completed. $parameters = $this->bindPathParameters($request); // If the route has a regular expression for the host part of the URI, we will diff --git a/src/Illuminate/Routing/RouteRegistrar.php b/src/Illuminate/Routing/RouteRegistrar.php index dc28c9f4d4931ff94e84852766eb7e6ad4327381..64c1359bd61d49919e54bb89d1cf2407ad419d27 100644 --- a/src/Illuminate/Routing/RouteRegistrar.php +++ b/src/Illuminate/Routing/RouteRegistrar.php @@ -17,12 +17,15 @@ use InvalidArgumentException; * @method \Illuminate\Routing\Route options(string $uri, \Closure|array|string|null $action = null) * @method \Illuminate\Routing\Route any(string $uri, \Closure|array|string|null $action = null) * @method \Illuminate\Routing\RouteRegistrar as(string $value) + * @method \Illuminate\Routing\RouteRegistrar controller(string $controller) * @method \Illuminate\Routing\RouteRegistrar domain(string $value) * @method \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) * @method \Illuminate\Routing\RouteRegistrar name(string $value) - * @method \Illuminate\Routing\RouteRegistrar namespace(string $value) + * @method \Illuminate\Routing\RouteRegistrar namespace(string|null $value) * @method \Illuminate\Routing\RouteRegistrar prefix(string $prefix) + * @method \Illuminate\Routing\RouteRegistrar scopeBindings() * @method \Illuminate\Routing\RouteRegistrar where(array $where) + * @method \Illuminate\Routing\RouteRegistrar withoutMiddleware(array|string $middleware) */ class RouteRegistrar { @@ -43,7 +46,7 @@ class RouteRegistrar /** * The methods to dynamically pass through to the router. * - * @var array + * @var string[] */ protected $passthru = [ 'get', 'post', 'put', 'patch', 'delete', 'options', 'any', @@ -52,10 +55,19 @@ class RouteRegistrar /** * The attributes that can be set through this class. * - * @var array + * @var string[] */ protected $allowedAttributes = [ - 'as', 'domain', 'middleware', 'name', 'namespace', 'prefix', 'where', + 'as', + 'controller', + 'domain', + 'middleware', + 'name', + 'namespace', + 'prefix', + 'scopeBindings', + 'where', + 'withoutMiddleware', ]; /** @@ -65,6 +77,8 @@ class RouteRegistrar */ protected $aliases = [ 'name' => 'as', + 'scopeBindings' => 'scope_bindings', + 'withoutMiddleware' => 'excluded_middleware', ]; /** @@ -93,7 +107,21 @@ class RouteRegistrar throw new InvalidArgumentException("Attribute [{$key}] does not exist."); } - $this->attributes[Arr::get($this->aliases, $key, $key)] = $value; + if ($key === 'middleware') { + foreach ($value as $index => $middleware) { + $value[$index] = (string) $middleware; + } + } + + $attributeKey = Arr::get($this->aliases, $key, $key); + + if ($key === 'withoutMiddleware') { + $value = array_merge( + (array) ($this->attributes[$attributeKey] ?? []), Arr::wrap($value) + ); + } + + $this->attributes[$attributeKey] = $value; return $this; } @@ -111,6 +139,19 @@ class RouteRegistrar return $this->router->resource($name, $controller, $this->attributes + $options); } + /** + * Route an API resource to a controller. + * + * @param string $name + * @param string $controller + * @param array $options + * @return \Illuminate\Routing\PendingResourceRegistration + */ + public function apiResource($name, $controller, array $options = []) + { + return $this->router->apiResource($name, $controller, $this->attributes + $options); + } + /** * Create a route group with shared attributes. * @@ -171,6 +212,9 @@ class RouteRegistrar if (is_array($action) && ! Arr::isAssoc($action) && Reflector::isCallable($action)) { + if (strncmp($action[0], '\\', 1)) { + $action[0] = '\\'.$action[0]; + } $action = [ 'uses' => $action[0].'@'.$action[1], 'controller' => $action[0].'@'.$action[1], @@ -200,7 +244,7 @@ class RouteRegistrar return $this->attribute($method, is_array($parameters[0]) ? $parameters[0] : $parameters); } - return $this->attribute($method, $parameters[0]); + return $this->attribute($method, array_key_exists(0, $parameters) ? $parameters[0] : true); } throw new BadMethodCallException(sprintf( diff --git a/src/Illuminate/Routing/RouteSignatureParameters.php b/src/Illuminate/Routing/RouteSignatureParameters.php index bd7e932fb316839c70f0342fa3517695706ba64b..03d3137b511c14c063a9077d6be399bdfa3146b6 100644 --- a/src/Illuminate/Routing/RouteSignatureParameters.php +++ b/src/Illuminate/Routing/RouteSignatureParameters.php @@ -18,9 +18,13 @@ class RouteSignatureParameters */ public static function fromAction(array $action, $subClass = null) { - $parameters = is_string($action['uses']) - ? static::fromClassMethodString($action['uses']) - : (new ReflectionFunction($action['uses']))->getParameters(); + $callback = RouteAction::containsSerializedClosure($action) + ? unserialize($action['uses'])->getClosure() + : $action['uses']; + + $parameters = is_string($callback) + ? static::fromClassMethodString($callback) + : (new ReflectionFunction($callback))->getParameters(); return is_null($subClass) ? $parameters : array_filter($parameters, function ($p) use ($subClass) { return Reflector::isParameterSubclassOf($p, $subClass); diff --git a/src/Illuminate/Routing/RouteUri.php b/src/Illuminate/Routing/RouteUri.php new file mode 100644 index 0000000000000000000000000000000000000000..ad69527e0b1a532277264220c5fa44ed5cb26b89 --- /dev/null +++ b/src/Illuminate/Routing/RouteUri.php @@ -0,0 +1,62 @@ +<?php + +namespace Illuminate\Routing; + +class RouteUri +{ + /** + * The route URI. + * + * @var string + */ + public $uri; + + /** + * The fields that should be used when resolving bindings. + * + * @var array + */ + public $bindingFields = []; + + /** + * Create a new route URI instance. + * + * @param string $uri + * @param array $bindingFields + * @return void + */ + public function __construct(string $uri, array $bindingFields = []) + { + $this->uri = $uri; + $this->bindingFields = $bindingFields; + } + + /** + * Parse the given URI. + * + * @param string $uri + * @return static + */ + public static function parse($uri) + { + preg_match_all('/\{([\w\:]+?)\??\}/', $uri, $matches); + + $bindingFields = []; + + foreach ($matches[0] as $match) { + if (strpos($match, ':') === false) { + continue; + } + + $segments = explode(':', trim($match, '{}?')); + + $bindingFields[$segments[0]] = $segments[1]; + + $uri = strpos($match, '?') !== false + ? str_replace($match, '{'.$segments[0].'?}', $uri) + : str_replace($match, '{'.$segments[0].'}', $uri); + } + + return new static($uri, $bindingFields); + } +} diff --git a/src/Illuminate/Routing/RouteUrlGenerator.php b/src/Illuminate/Routing/RouteUrlGenerator.php index 5cc03c1e246c3a96f3804116a48d6bab810820a4..5f1248966c77b7b265beae4331052ca36c3ed42b 100644 --- a/src/Illuminate/Routing/RouteUrlGenerator.php +++ b/src/Illuminate/Routing/RouteUrlGenerator.php @@ -87,8 +87,8 @@ class RouteUrlGenerator $route ), $parameters); - if (preg_match('/\{.*?\}/', $uri)) { - throw UrlGenerationException::forMissingParameters($route); + if (preg_match_all('/{(.*?)}/', $uri, $matchedMissingParameters)) { + throw UrlGenerationException::forMissingParameters($route, $matchedMissingParameters[1]); } // Once we have ensured that there are no missing parameters in the URI we will encode diff --git a/src/Illuminate/Routing/Router.php b/src/Illuminate/Routing/Router.php index 8e762351ee8f6451111c5558a107b2268b8a7c9e..26f6ec9ba28d5defdaf11b10407112141e669b89 100644 --- a/src/Illuminate/Routing/Router.php +++ b/src/Illuminate/Routing/Router.php @@ -18,9 +18,11 @@ use Illuminate\Http\Response; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Illuminate\Support\Traits\Macroable; use JsonSerializable; use Psr\Http\Message\ResponseInterface as PsrResponseInterface; +use ReflectionClass; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -50,7 +52,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * The route collection instance. * - * @var \Illuminate\Routing\RouteCollection + * @var \Illuminate\Routing\RouteCollectionInterface */ protected $routes; @@ -115,7 +117,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * All of the verbs supported by the router. * - * @var array + * @var string[] */ public static $verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; @@ -137,7 +139,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new GET route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function get($uri, $action = null) @@ -149,7 +151,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new POST route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function post($uri, $action = null) @@ -161,7 +163,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new PUT route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function put($uri, $action = null) @@ -173,7 +175,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new PATCH route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function patch($uri, $action = null) @@ -185,7 +187,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new DELETE route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function delete($uri, $action = null) @@ -197,7 +199,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new OPTIONS route with the router. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function options($uri, $action = null) @@ -209,7 +211,7 @@ class Router implements BindingRegistrar, RegistrarContract * Register a new route responding to all verbs. * * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function any($uri, $action = null) @@ -220,7 +222,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * Register a new Fallback route with the router. * - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function fallback($action) @@ -265,13 +267,19 @@ class Router implements BindingRegistrar, RegistrarContract * @param string $uri * @param string $view * @param array $data + * @param int|array $status + * @param array $headers * @return \Illuminate\Routing\Route */ - public function view($uri, $view, $data = []) + public function view($uri, $view, $data = [], $status = 200, array $headers = []) { return $this->match(['GET', 'HEAD'], $uri, '\Illuminate\Routing\ViewController') - ->defaults('view', $view) - ->defaults('data', $data); + ->setDefaults([ + 'view' => $view, + 'data' => $data, + 'status' => is_array($status) ? 200 : $status, + 'headers' => is_array($status) ? $status : $headers, + ]); } /** @@ -279,7 +287,7 @@ class Router implements BindingRegistrar, RegistrarContract * * @param array|string $methods * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function match($methods, $uri, $action = null) @@ -395,11 +403,12 @@ class Router implements BindingRegistrar, RegistrarContract * Merge the given array with the last group stack. * * @param array $new + * @param bool $prependExistingPrefix * @return array */ - public function mergeWithLastGroup($new) + public function mergeWithLastGroup($new, $prependExistingPrefix = true) { - return RouteGroup::merge($new, end($this->groupStack)); + return RouteGroup::merge($new, end($this->groupStack), $prependExistingPrefix); } /** @@ -438,7 +447,7 @@ class Router implements BindingRegistrar, RegistrarContract * * @param array|string $methods * @param string $uri - * @param \Closure|array|string|callable|null $action + * @param array|string|callable|null $action * @return \Illuminate\Routing\Route */ public function addRoute($methods, $uri, $action) @@ -482,7 +491,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * Determine if the action is routing to a controller. * - * @param array $action + * @param mixed $action * @return bool */ protected function actionReferencesController($action) @@ -506,10 +515,11 @@ class Router implements BindingRegistrar, RegistrarContract $action = ['uses' => $action]; } - // Here we'll merge any group "uses" statement if necessary so that the action - // has the proper clause for this property. Then we can simply set the name - // of the controller on the action and return the action array for usage. + // Here we'll merge any group "controller" and "uses" statements if necessary so that + // the action has the proper clause for this property. Then, we can simply set the + // name of this controller on the action plus return the action array for usage. if ($this->hasGroupStack()) { + $action['uses'] = $this->prependGroupController($action['uses']); $action['uses'] = $this->prependGroupNamespace($action['uses']); } @@ -535,6 +545,31 @@ class Router implements BindingRegistrar, RegistrarContract ? $group['namespace'].'\\'.$class : $class; } + /** + * Prepend the last group controller onto the use clause. + * + * @param string $class + * @return string + */ + protected function prependGroupController($class) + { + $group = end($this->groupStack); + + if (! isset($group['controller'])) { + return $class; + } + + if (class_exists($class)) { + return $class; + } + + if (strpos($class, '@') !== false) { + return $class; + } + + return $group['controller'].'@'.$class; + } + /** * Create a new Route object. * @@ -543,7 +578,7 @@ class Router implements BindingRegistrar, RegistrarContract * @param mixed $action * @return \Illuminate\Routing\Route */ - protected function newRoute($methods, $uri, $action) + public function newRoute($methods, $uri, $action) { return (new Route($methods, $uri, $action)) ->setRouter($this) @@ -584,7 +619,10 @@ class Router implements BindingRegistrar, RegistrarContract */ protected function mergeGroupAttributesIntoRoute($route) { - $route->setAction($this->mergeWithLastGroup($route->getAction())); + $route->setAction($this->mergeWithLastGroup( + $route->getAction(), + $prependExistingPrefix = false + )); } /** @@ -634,6 +672,8 @@ class Router implements BindingRegistrar, RegistrarContract { $this->current = $route = $this->routes->match($request); + $route->setContainer($this->container); + $this->container->instance(Route::class, $route); return $route; @@ -691,9 +731,37 @@ class Router implements BindingRegistrar, RegistrarContract */ public function gatherRouteMiddleware(Route $route) { - $middleware = collect($route->gatherMiddleware())->map(function ($name) { + $computedMiddleware = $route->gatherMiddleware(); + + $excluded = collect($route->excludedMiddleware())->map(function ($name) { + return (array) MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups); + })->flatten()->values()->all(); + + $middleware = collect($computedMiddleware)->map(function ($name) { return (array) MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups); - })->flatten(); + })->flatten()->reject(function ($name) use ($excluded) { + if (empty($excluded)) { + return false; + } + + if ($name instanceof Closure) { + return false; + } + + if (in_array($name, $excluded, true)) { + return true; + } + + if (! class_exists($name)) { + return false; + } + + $reflection = new ReflectionClass($name); + + return collect($excluded)->contains(function ($exclude) use ($reflection) { + return class_exists($exclude) && $reflection->isSubclassOf($exclude); + }); + })->values(); return $this->sortMiddleware($middleware); } @@ -738,11 +806,14 @@ class Router implements BindingRegistrar, RegistrarContract $response = (new HttpFoundationFactory)->createResponse($response); } elseif ($response instanceof Model && $response->wasRecentlyCreated) { $response = new JsonResponse($response, 201); + } elseif ($response instanceof Stringable) { + $response = new Response($response->__toString(), 200, ['Content-Type' => 'text/html']); } elseif (! $response instanceof SymfonyResponse && ($response instanceof Arrayable || $response instanceof Jsonable || $response instanceof ArrayObject || $response instanceof JsonSerializable || + $response instanceof \stdClass || is_array($response))) { $response = new JsonResponse($response); } elseif (! $response instanceof SymfonyResponse) { @@ -913,6 +984,18 @@ class Router implements BindingRegistrar, RegistrarContract return $this; } + /** + * Flush the router's middleware groups. + * + * @return $this + */ + public function flushMiddlewareGroups() + { + $this->middlewareGroups = []; + + return $this; + } + /** * Add a new route parameter binder. * @@ -1033,7 +1116,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * Get the currently dispatched route instance. * - * @return \Illuminate\Routing\Route + * @return \Illuminate\Routing\Route|null */ public function getCurrentRoute() { @@ -1141,78 +1224,6 @@ class Router implements BindingRegistrar, RegistrarContract return $this->currentRouteAction() == $action; } - /** - * Register the typical authentication routes for an application. - * - * @param array $options - * @return void - */ - public function auth(array $options = []) - { - // Authentication Routes... - $this->get('login', 'Auth\LoginController@showLoginForm')->name('login'); - $this->post('login', 'Auth\LoginController@login'); - $this->post('logout', 'Auth\LoginController@logout')->name('logout'); - - // Registration Routes... - if ($options['register'] ?? true) { - $this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register'); - $this->post('register', 'Auth\RegisterController@register'); - } - - // Password Reset Routes... - if ($options['reset'] ?? true) { - $this->resetPassword(); - } - - // Password Confirmation Routes... - if ($options['confirm'] ?? - class_exists($this->prependGroupNamespace('Auth\ConfirmPasswordController'))) { - $this->confirmPassword(); - } - - // Email Verification Routes... - if ($options['verify'] ?? false) { - $this->emailVerification(); - } - } - - /** - * Register the typical reset password routes for an application. - * - * @return void - */ - public function resetPassword() - { - $this->get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm')->name('password.request'); - $this->post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email'); - $this->get('password/reset/{token}', 'Auth\ResetPasswordController@showResetForm')->name('password.reset'); - $this->post('password/reset', 'Auth\ResetPasswordController@reset')->name('password.update'); - } - - /** - * Register the typical confirm password routes for an application. - * - * @return void - */ - public function confirmPassword() - { - $this->get('password/confirm', 'Auth\ConfirmPasswordController@showConfirmForm')->name('password.confirm'); - $this->post('password/confirm', 'Auth\ConfirmPasswordController@confirm'); - } - - /** - * Register the typical email verification routes for an application. - * - * @return void - */ - public function emailVerification() - { - $this->get('email/verify', 'Auth\VerificationController@show')->name('verification.notice'); - $this->get('email/verify/{id}/{hash}', 'Auth\VerificationController@verify')->name('verification.verify'); - $this->post('email/resend', 'Auth\VerificationController@resend')->name('verification.resend'); - } - /** * Set the unmapped global resource parameters to singular. * @@ -1249,7 +1260,7 @@ class Router implements BindingRegistrar, RegistrarContract /** * Get the underlying route collection. * - * @return \Illuminate\Routing\RouteCollection + * @return \Illuminate\Routing\RouteCollectionInterface */ public function getRoutes() { @@ -1273,6 +1284,21 @@ class Router implements BindingRegistrar, RegistrarContract $this->container->instance('routes', $this->routes); } + /** + * Set the compiled route collection instance. + * + * @param array $routes + * @return void + */ + public function setCompiledRoutes(array $routes) + { + $this->routes = (new CompiledRouteCollection($routes['compiled'], $routes['attributes'])) + ->setRouter($this) + ->setContainer($this->container); + + $this->container->instance('routes', $this->routes); + } + /** * Remove any duplicate middleware from the given array. * @@ -1296,6 +1322,19 @@ class Router implements BindingRegistrar, RegistrarContract return $result; } + /** + * Set the container instance used by the router. + * + * @param \Illuminate\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } + /** * Dynamically handle calls into the router instance. * @@ -1313,6 +1352,6 @@ class Router implements BindingRegistrar, RegistrarContract return (new RouteRegistrar($this))->attribute($method, is_array($parameters[0]) ? $parameters[0] : $parameters); } - return (new RouteRegistrar($this))->attribute($method, $parameters[0]); + return (new RouteRegistrar($this))->attribute($method, array_key_exists(0, $parameters) ? $parameters[0] : true); } } diff --git a/src/Illuminate/Routing/RoutingServiceProvider.php b/src/Illuminate/Routing/RoutingServiceProvider.php index deed73f6a80493e57698e4e15ec82ad85cd487cf..ee0986317e553780649eacd03ed9d3779de41703 100755 --- a/src/Illuminate/Routing/RoutingServiceProvider.php +++ b/src/Illuminate/Routing/RoutingServiceProvider.php @@ -9,13 +9,10 @@ use Illuminate\Contracts\View\Factory as ViewFactoryContract; use Illuminate\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract; use Illuminate\Support\ServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7\Response as NyholmPsrResponse; +use Nyholm\Psr7\Response as PsrResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Symfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Zend\Diactoros\Response as ZendPsrResponse; -use Zend\Diactoros\ServerRequestFactory; class RoutingServiceProvider extends ServiceProvider { @@ -129,6 +126,8 @@ class RoutingServiceProvider extends ServiceProvider * Register a binding for the PSR-7 request implementation. * * @return void + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ protected function registerPsrRequest() { @@ -140,10 +139,6 @@ class RoutingServiceProvider extends ServiceProvider ->createRequest($app->make('request')); } - if (class_exists(ServerRequestFactory::class) && class_exists(DiactorosFactory::class)) { - return (new DiactorosFactory)->createRequest($app->make('request')); - } - throw new BindingResolutionException('Unable to resolve PSR request. Please install the symfony/psr-http-message-bridge and nyholm/psr7 packages.'); }); } @@ -152,16 +147,14 @@ class RoutingServiceProvider extends ServiceProvider * Register a binding for the PSR-7 response implementation. * * @return void + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException */ protected function registerPsrResponse() { $this->app->bind(ResponseInterface::class, function () { - if (class_exists(NyholmPsrResponse::class)) { - return new NyholmPsrResponse; - } - - if (class_exists(ZendPsrResponse::class)) { - return new ZendPsrResponse; + if (class_exists(PsrResponse::class)) { + return new PsrResponse; } throw new BindingResolutionException('Unable to resolve PSR response. Please install the nyholm/psr7 package.'); diff --git a/src/Illuminate/Routing/SortedMiddleware.php b/src/Illuminate/Routing/SortedMiddleware.php index 57dbb0730a38690dad250a8eb3a2344d5edb83dd..853378cf7e926983383ce07b34384b0da0359dcc 100644 --- a/src/Illuminate/Routing/SortedMiddleware.php +++ b/src/Illuminate/Routing/SortedMiddleware.php @@ -40,11 +40,9 @@ class SortedMiddleware extends Collection continue; } - $stripped = head(explode(':', $middleware)); - - if (in_array($stripped, $priorityMap)) { - $priorityIndex = array_search($stripped, $priorityMap); + $priorityIndex = $this->priorityMapIndex($priorityMap, $middleware); + if (! is_null($priorityIndex)) { // This middleware is in the priority map. If we have encountered another middleware // that was also in the priority map and was at a lower priority than the current // middleware, we will move this middleware to be above the previous encounter. @@ -58,6 +56,7 @@ class SortedMiddleware extends Collection // encountered from the map thus far. We'll save its current index plus its index // from the priority map so we can compare against them on the next iterations. $lastIndex = $index; + $lastPriorityIndex = $priorityIndex; } } @@ -65,6 +64,45 @@ class SortedMiddleware extends Collection return Router::uniqueMiddleware($middlewares); } + /** + * Calculate the priority map index of the middleware. + * + * @param array $priorityMap + * @param string $middleware + * @return int|null + */ + protected function priorityMapIndex($priorityMap, $middleware) + { + foreach ($this->middlewareNames($middleware) as $name) { + $priorityIndex = array_search($name, $priorityMap); + + if ($priorityIndex !== false) { + return $priorityIndex; + } + } + } + + /** + * Resolve the middleware names to look for in the priority array. + * + * @param string $middleware + * @return \Generator + */ + protected function middlewareNames($middleware) + { + $stripped = head(explode(':', $middleware)); + + yield $stripped; + + $interfaces = @class_implements($stripped); + + if ($interfaces !== false) { + foreach ($interfaces as $interface) { + yield $interface; + } + } + } + /** * Splice a middleware into a new position and remove the old entry. * diff --git a/src/Illuminate/Routing/UrlGenerator.php b/src/Illuminate/Routing/UrlGenerator.php index 63e344aca2137420973fa11da51a072a87e31e34..f40491de5039d46dbea49f702aea70c7b519ff23 100755 --- a/src/Illuminate/Routing/UrlGenerator.php +++ b/src/Illuminate/Routing/UrlGenerator.php @@ -21,7 +21,7 @@ class UrlGenerator implements UrlGeneratorContract /** * The route collection. * - * @var \Illuminate\Routing\RouteCollection + * @var \Illuminate\Routing\RouteCollectionInterface */ protected $routes; @@ -112,12 +112,12 @@ class UrlGenerator implements UrlGeneratorContract /** * Create a new URL Generator instance. * - * @param \Illuminate\Routing\RouteCollection $routes + * @param \Illuminate\Routing\RouteCollectionInterface $routes * @param \Illuminate\Http\Request $request * @param string|null $assetRoot * @return void */ - public function __construct(RouteCollection $routes, Request $request, $assetRoot = null) + public function __construct(RouteCollectionInterface $routes, Request $request, $assetRoot = null) { $this->routes = $routes; $this->assetRoot = $assetRoot; @@ -239,9 +239,7 @@ class UrlGenerator implements UrlGeneratorContract // Once we get the root URL, we will check to see if it contains an index.php // file in the paths. If it does, we will remove it since it is not needed // for asset paths, but only for routes to endpoints in the application. - $root = $this->assetRoot - ? $this->assetRoot - : $this->formatRoot($this->formatScheme($secure)); + $root = $this->assetRoot ?: $this->formatRoot($this->formatScheme($secure)); return $this->removeIndex($root).'/'.trim($path, '/'); } @@ -320,13 +318,9 @@ class UrlGenerator implements UrlGeneratorContract */ public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true) { - $parameters = $this->formatParameters($parameters); - - if (array_key_exists('signature', $parameters)) { - throw new InvalidArgumentException( - '"Signature" is a reserved parameter when generating signed routes. Please rename your route parameter.' - ); - } + $this->ensureSignedRouteParametersAreNotReserved( + $parameters = Arr::wrap($parameters) + ); if ($expiration) { $parameters = $parameters + ['expires' => $this->availableAt($expiration)]; @@ -341,6 +335,27 @@ class UrlGenerator implements UrlGeneratorContract ], $absolute); } + /** + * Ensure the given signed route parameters are not reserved. + * + * @param mixed $parameters + * @return void + */ + protected function ensureSignedRouteParametersAreNotReserved($parameters) + { + if (array_key_exists('signature', $parameters)) { + throw new InvalidArgumentException( + '"Signature" is a reserved parameter when generating signed routes. Please rename your route parameter.' + ); + } + + if (array_key_exists('expires', $parameters)) { + throw new InvalidArgumentException( + '"Expires" is a reserved parameter when generating signed routes. Please rename your route parameter.' + ); + } + } + /** * Create a temporary signed route URL for a named route. * @@ -368,6 +383,17 @@ class UrlGenerator implements UrlGeneratorContract && $this->signatureHasNotExpired($request); } + /** + * Determine if the given request has a valid signature for a relative URL. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + public function hasValidRelativeSignature(Request $request) + { + return $this->hasValidSignature($request, false); + } + /** * Determine if the signature from the given request matches the URL. * @@ -379,11 +405,9 @@ class UrlGenerator implements UrlGeneratorContract { $url = $absolute ? $request->url() : '/'.$request->path(); - $original = rtrim($url.'?'.Arr::query( - Arr::except($request->query(), 'signature') - ), '?'); + $queryString = ltrim(preg_replace('/(^|&)signature=[^&]+/', '', $request->server->get('QUERY_STRING')), '&'); - $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver)); + $signature = hash_hmac('sha256', rtrim($url.'?'.$queryString, '?'), call_user_func($this->keyResolver)); return hash_equals($signature, (string) $request->query('signature', '')); } @@ -432,6 +456,12 @@ class UrlGenerator implements UrlGeneratorContract */ public function toRoute($route, $parameters, $absolute) { + $parameters = collect(Arr::wrap($parameters))->map(function ($value, $key) use ($route) { + return $value instanceof UrlRoutable && $route->bindingFieldFor($key) + ? $value->{$route->bindingFieldFor($key)} + : $value; + })->all(); + return $this->routeUrl()->to( $route, $this->formatParameters($parameters), $absolute ); @@ -610,25 +640,25 @@ class UrlGenerator implements UrlGeneratorContract /** * Force the scheme for URLs. * - * @param string $scheme + * @param string|null $scheme * @return void */ public function forceScheme($scheme) { $this->cachedScheme = null; - $this->forceScheme = $scheme.'://'; + $this->forceScheme = $scheme ? $scheme.'://' : null; } /** * Set the forced root URL. * - * @param string $root + * @param string|null $root * @return void */ public function forceRootUrl($root) { - $this->forcedRoot = rtrim($root, '/'); + $this->forcedRoot = $root ? rtrim($root, '/') : null; $this->cachedRoot = null; } @@ -693,16 +723,23 @@ class UrlGenerator implements UrlGeneratorContract $this->cachedRoot = null; $this->cachedScheme = null; - $this->routeGenerator = null; + + tap(optional($this->routeGenerator)->defaultParameters ?: [], function ($defaults) { + $this->routeGenerator = null; + + if (! empty($defaults)) { + $this->defaults($defaults); + } + }); } /** * Set the route collection. * - * @param \Illuminate\Routing\RouteCollection $routes + * @param \Illuminate\Routing\RouteCollectionInterface $routes * @return $this */ - public function setRoutes(RouteCollection $routes) + public function setRoutes(RouteCollectionInterface $routes) { $this->routes = $routes; diff --git a/src/Illuminate/Routing/ViewController.php b/src/Illuminate/Routing/ViewController.php index 232013f82609cb4d520150317295d3231e4bf4f7..7829cca832b1b828892688b9d445a46b900bed9d 100644 --- a/src/Illuminate/Routing/ViewController.php +++ b/src/Illuminate/Routing/ViewController.php @@ -2,38 +2,38 @@ namespace Illuminate\Routing; -use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Contracts\Routing\ResponseFactory; class ViewController extends Controller { /** - * The view factory implementation. + * The response factory implementation. * - * @var \Illuminate\Contracts\View\Factory + * @var \Illuminate\Contracts\Routing\ResponseFactory */ - protected $view; + protected $response; /** * Create a new controller instance. * - * @param \Illuminate\Contracts\View\Factory $view + * @param \Illuminate\Contracts\Routing\ResponseFactory $response * @return void */ - public function __construct(ViewFactory $view) + public function __construct(ResponseFactory $response) { - $this->view = $view; + $this->response = $response; } /** * Invoke the controller method. * * @param array $args - * @return \Illuminate\Contracts\View\View + * @return \Illuminate\Http\Response */ public function __invoke(...$args) { - [$view, $data] = array_slice($args, -2); + [$view, $data, $status, $headers] = array_slice($args, -4); - return $this->view->make($view, $data); + return $this->response->view($view, $data, $status, $headers); } } diff --git a/src/Illuminate/Routing/composer.json b/src/Illuminate/Routing/composer.json index 026fc96518cb0ecb70d237a159be2d264f526acb..96c3488e4ba3d06930382aa9e6d9d02266d84ec9 100644 --- a/src/Illuminate/Routing/composer.json +++ b/src/Illuminate/Routing/composer.json @@ -14,18 +14,19 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/http": "^6.0", - "illuminate/pipeline": "^6.0", - "illuminate/session": "^6.0", - "illuminate/support": "^6.0", - "symfony/debug": "^4.3.4", - "symfony/http-foundation": "^4.3.4", - "symfony/http-kernel": "^4.3.4", - "symfony/routing": "^4.3.4" + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/http": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/pipeline": "^8.0", + "illuminate/session": "^8.0", + "illuminate/support": "^8.0", + "symfony/http-foundation": "^5.4", + "symfony/http-kernel": "^5.4", + "symfony/routing": "^5.4" }, "autoload": { "psr-4": { @@ -34,13 +35,13 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/console": "Required to use the make commands (^6.0).", + "illuminate/console": "Required to use the make commands (^8.0).", "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^1.2)." + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Session/ArraySessionHandler.php b/src/Illuminate/Session/ArraySessionHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..9a0dff1850fc0f8c4a5b5b3cb16b64b07d99ae9e --- /dev/null +++ b/src/Illuminate/Session/ArraySessionHandler.php @@ -0,0 +1,142 @@ +<?php + +namespace Illuminate\Session; + +use Illuminate\Support\InteractsWithTime; +use SessionHandlerInterface; + +class ArraySessionHandler implements SessionHandlerInterface +{ + use InteractsWithTime; + + /** + * The array of stored values. + * + * @var array + */ + protected $storage = []; + + /** + * The number of minutes the session should be valid. + * + * @var int + */ + protected $minutes; + + /** + * Create a new array driven handler instance. + * + * @param int $minutes + * @return void + */ + public function __construct($minutes) + { + $this->minutes = $minutes; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function open($savePath, $sessionName) + { + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function close() + { + return true; + } + + /** + * {@inheritdoc} + * + * @return string|false + */ + #[\ReturnTypeWillChange] + public function read($sessionId) + { + if (! isset($this->storage[$sessionId])) { + return ''; + } + + $session = $this->storage[$sessionId]; + + $expiration = $this->calculateExpiration($this->minutes * 60); + + if (isset($session['time']) && $session['time'] >= $expiration) { + return $session['data']; + } + + return ''; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function write($sessionId, $data) + { + $this->storage[$sessionId] = [ + 'data' => $data, + 'time' => $this->currentTime(), + ]; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + */ + #[\ReturnTypeWillChange] + public function destroy($sessionId) + { + if (isset($this->storage[$sessionId])) { + unset($this->storage[$sessionId]); + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return int|false + */ + #[\ReturnTypeWillChange] + public function gc($lifetime) + { + $expiration = $this->calculateExpiration($lifetime); + + foreach ($this->storage as $sessionId => $session) { + if ($session['time'] < $expiration) { + unset($this->storage[$sessionId]); + } + } + + return true; + } + + /** + * Get the expiration time of the session. + * + * @param int $seconds + * @return int + */ + protected function calculateExpiration($seconds) + { + return $this->currentTime() - $seconds; + } +} diff --git a/src/Illuminate/Session/CacheBasedSessionHandler.php b/src/Illuminate/Session/CacheBasedSessionHandler.php index 5f35f75731760e49da166aa0faf24f17a0ff7053..5db2ac55537f25dc45ee34e0a6bbfcf3ec7a02d5 100755 --- a/src/Illuminate/Session/CacheBasedSessionHandler.php +++ b/src/Illuminate/Session/CacheBasedSessionHandler.php @@ -36,7 +36,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function open($savePath, $sessionName) { return true; @@ -44,7 +47,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function close() { return true; @@ -52,7 +58,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return string|false */ + #[\ReturnTypeWillChange] public function read($sessionId) { return $this->cache->get($sessionId, ''); @@ -60,7 +69,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function write($sessionId, $data) { return $this->cache->put($sessionId, $data, $this->minutes * 60); @@ -68,7 +80,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function destroy($sessionId) { return $this->cache->forget($sessionId); @@ -76,7 +91,10 @@ class CacheBasedSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return int|false */ + #[\ReturnTypeWillChange] public function gc($lifetime) { return true; diff --git a/src/Illuminate/Session/Console/stubs/database.stub b/src/Illuminate/Session/Console/stubs/database.stub index c7e23cd9d31bed67464df303ca5d4c697b722792..88b4a316e6cd2270d148cdc2212c823911bee7e5 100755 --- a/src/Illuminate/Session/Console/stubs/database.stub +++ b/src/Illuminate/Session/Console/stubs/database.stub @@ -14,12 +14,12 @@ class CreateSessionsTable extends Migration public function up() { Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->unique(); - $table->unsignedBigInteger('user_id')->nullable(); + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); $table->text('payload'); - $table->integer('last_activity'); + $table->integer('last_activity')->index(); }); } diff --git a/src/Illuminate/Session/CookieSessionHandler.php b/src/Illuminate/Session/CookieSessionHandler.php index 998b78fabf133a2ebf1c1e0aa4b71e1b442dde11..0a1d9cd4e188d4c9ceb74d3e834acca07bceac94 100755 --- a/src/Illuminate/Session/CookieSessionHandler.php +++ b/src/Illuminate/Session/CookieSessionHandler.php @@ -47,7 +47,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function open($savePath, $sessionName) { return true; @@ -55,7 +58,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function close() { return true; @@ -63,7 +69,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return string|false */ + #[\ReturnTypeWillChange] public function read($sessionId) { $value = $this->request->cookies->get($sessionId) ?: ''; @@ -79,7 +88,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function write($sessionId, $data) { $this->cookie->queue($sessionId, json_encode([ @@ -92,7 +104,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function destroy($sessionId) { $this->cookie->queue($this->cookie->forget($sessionId)); @@ -102,7 +117,10 @@ class CookieSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return int|false */ + #[\ReturnTypeWillChange] public function gc($lifetime) { return true; diff --git a/src/Illuminate/Session/DatabaseSessionHandler.php b/src/Illuminate/Session/DatabaseSessionHandler.php index 7781a0131ff3ac3ad8b0e04a96e8cb94505143fe..18846add33c09226af9959a14d6db93101b2fb3f 100644 --- a/src/Illuminate/Session/DatabaseSessionHandler.php +++ b/src/Illuminate/Session/DatabaseSessionHandler.php @@ -69,7 +69,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function open($savePath, $sessionName) { return true; @@ -77,7 +80,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function close() { return true; @@ -85,7 +91,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return string|false */ + #[\ReturnTypeWillChange] public function read($sessionId) { $session = (object) $this->getQuery()->find($sessionId); @@ -119,7 +128,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function write($sessionId, $data) { $payload = $this->getDefaultPayload($data); @@ -141,7 +153,7 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI * Perform an insert operation on the session ID. * * @param string $sessionId - * @param string $payload + * @param array<string, mixed> $payload * @return bool|null */ protected function performInsert($sessionId, $payload) @@ -157,7 +169,7 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI * Perform an update operation on the session ID. * * @param string $sessionId - * @param string $payload + * @param array<string, mixed> $payload * @return int */ protected function performUpdate($sessionId, $payload) @@ -253,7 +265,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function destroy($sessionId) { $this->getQuery()->where('id', $sessionId)->delete(); @@ -263,7 +278,10 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI /** * {@inheritdoc} + * + * @return int|false */ + #[\ReturnTypeWillChange] public function gc($lifetime) { $this->getQuery()->where('last_activity', '<=', $this->currentTime() - $lifetime)->delete(); @@ -279,6 +297,19 @@ class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerI return $this->connection->table($this->table); } + /** + * Set the application instance used by the handler. + * + * @param \Illuminate\Contracts\Foundation\Application $container + * @return $this + */ + public function setContainer($container) + { + $this->container = $container; + + return $this; + } + /** * Set the existence state for the session. * diff --git a/src/Illuminate/Session/FileSessionHandler.php b/src/Illuminate/Session/FileSessionHandler.php index 190c23502fe06c72401f907a6b6de2b2592769c7..27c5e8fb52f9826391a604342602c6113e84d7c9 100644 --- a/src/Illuminate/Session/FileSessionHandler.php +++ b/src/Illuminate/Session/FileSessionHandler.php @@ -47,7 +47,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function open($savePath, $sessionName) { return true; @@ -55,7 +58,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function close() { return true; @@ -63,7 +69,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return string|false */ + #[\ReturnTypeWillChange] public function read($sessionId) { if ($this->files->isFile($path = $this->path.'/'.$sessionId)) { @@ -77,7 +86,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function write($sessionId, $data) { $this->files->put($this->path.'/'.$sessionId, $data, true); @@ -87,7 +99,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function destroy($sessionId) { $this->files->delete($this->path.'/'.$sessionId); @@ -97,7 +112,10 @@ class FileSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return int|false */ + #[\ReturnTypeWillChange] public function gc($lifetime) { $files = Finder::create() diff --git a/src/Illuminate/Session/Middleware/AuthenticateSession.php b/src/Illuminate/Session/Middleware/AuthenticateSession.php index 5da389ae3d399577427585e0c1e2281f791ba2ca..42f3a4f4f1fe24f362924bcc91b4c64dafebe569 100644 --- a/src/Illuminate/Session/Middleware/AuthenticateSession.php +++ b/src/Illuminate/Session/Middleware/AuthenticateSession.php @@ -39,7 +39,7 @@ class AuthenticateSession return $next($request); } - if ($this->auth->viaRemember()) { + if ($this->guard()->viaRemember()) { $passwordHash = explode('|', $request->cookies->get($this->auth->getRecallerName()))[2] ?? null; if (! $passwordHash || $passwordHash != $request->user()->getAuthPassword()) { @@ -47,16 +47,18 @@ class AuthenticateSession } } - if (! $request->session()->has('password_hash')) { + if (! $request->session()->has('password_hash_'.$this->auth->getDefaultDriver())) { $this->storePasswordHashInSession($request); } - if ($request->session()->get('password_hash') !== $request->user()->getAuthPassword()) { + if ($request->session()->get('password_hash_'.$this->auth->getDefaultDriver()) !== $request->user()->getAuthPassword()) { $this->logout($request); } return tap($next($request), function () use ($request) { - $this->storePasswordHashInSession($request); + if (! is_null($this->guard()->user())) { + $this->storePasswordHashInSession($request); + } }); } @@ -73,7 +75,7 @@ class AuthenticateSession } $request->session()->put([ - 'password_hash' => $request->user()->getAuthPassword(), + 'password_hash_'.$this->auth->getDefaultDriver() => $request->user()->getAuthPassword(), ]); } @@ -87,10 +89,20 @@ class AuthenticateSession */ protected function logout($request) { - $this->auth->logoutCurrentDevice(); + $this->guard()->logoutCurrentDevice(); $request->session()->flush(); - throw new AuthenticationException; + throw new AuthenticationException('Unauthenticated.', [$this->auth->getDefaultDriver()]); + } + + /** + * Get the guard instance that should be used by the middleware. + * + * @return \Illuminate\Contracts\Auth\Factory|\Illuminate\Contracts\Auth\Guard + */ + protected function guard() + { + return $this->auth; } } diff --git a/src/Illuminate/Session/Middleware/StartSession.php b/src/Illuminate/Session/Middleware/StartSession.php index 31e48c179fef2e5574043097427e8b73353f81e6..e7d2daa223156841c36c6cd4096723cde198fe99 100644 --- a/src/Illuminate/Session/Middleware/StartSession.php +++ b/src/Illuminate/Session/Middleware/StartSession.php @@ -5,6 +5,7 @@ namespace Illuminate\Session\Middleware; use Closure; use Illuminate\Contracts\Session\Session; use Illuminate\Http\Request; +use Illuminate\Routing\Route; use Illuminate\Session\SessionManager; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; @@ -20,15 +21,24 @@ class StartSession */ protected $manager; + /** + * The callback that can resolve an instance of the cache factory. + * + * @var callable|null + */ + protected $cacheFactoryResolver; + /** * Create a new session middleware. * * @param \Illuminate\Session\SessionManager $manager + * @param callable|null $cacheFactoryResolver * @return void */ - public function __construct(SessionManager $manager) + public function __construct(SessionManager $manager, callable $cacheFactoryResolver = null) { $this->manager = $manager; + $this->cacheFactoryResolver = $cacheFactoryResolver; } /** @@ -44,11 +54,66 @@ class StartSession return $next($request); } + $session = $this->getSession($request); + + if ($this->manager->shouldBlock() || + ($request->route() instanceof Route && $request->route()->locksFor())) { + return $this->handleRequestWhileBlocking($request, $session, $next); + } + + return $this->handleStatefulRequest($request, $session, $next); + } + + /** + * Handle the given request within session state. + * + * @param \Illuminate\Http\Request $request + * @param \Illuminate\Contracts\Session\Session $session + * @param \Closure $next + * @return mixed + */ + protected function handleRequestWhileBlocking(Request $request, $session, Closure $next) + { + if (! $request->route() instanceof Route) { + return; + } + + $lockFor = $request->route() && $request->route()->locksFor() + ? $request->route()->locksFor() + : 10; + + $lock = $this->cache($this->manager->blockDriver()) + ->lock('session:'.$session->getId(), $lockFor) + ->betweenBlockedAttemptsSleepFor(50); + + try { + $lock->block( + ! is_null($request->route()->waitsFor()) + ? $request->route()->waitsFor() + : 10 + ); + + return $this->handleStatefulRequest($request, $session, $next); + } finally { + optional($lock)->release(); + } + } + + /** + * Handle the given request within session state. + * + * @param \Illuminate\Http\Request $request + * @param \Illuminate\Contracts\Session\Session $session + * @param \Closure $next + * @return mixed + */ + protected function handleStatefulRequest(Request $request, $session, Closure $next) + { // If a session driver has been configured, we will need to start the session here // so that the data is ready for an application. Note that the Laravel sessions // do not make use of PHP "native" sessions in any way since they are crappy. $request->setLaravelSession( - $session = $this->startSession($request) + $this->startSession($request, $session) ); $this->collectGarbage($session); @@ -71,11 +136,12 @@ class StartSession * Start the session for the given request. * * @param \Illuminate\Http\Request $request + * @param \Illuminate\Contracts\Session\Session $session * @return \Illuminate\Contracts\Session\Session */ - protected function startSession(Request $request) + protected function startSession(Request $request, $session) { - return tap($this->getSession($request), function ($session) use ($request) { + return tap($session, function ($session) use ($request) { $session->setRequestOnHandler($request); $session->start(); @@ -134,7 +200,7 @@ class StartSession protected function storeCurrentUrl(Request $request, $session) { if ($request->method() === 'GET' && - $request->route() && + $request->route() instanceof Route && ! $request->ajax() && ! $request->prefetch()) { $session->setPreviousUrl($request->fullUrl()); @@ -214,6 +280,17 @@ class StartSession { $config = $config ?: $this->manager->getSessionConfig(); - return ! in_array($config['driver'], [null, 'array']); + return ! is_null($config['driver'] ?? null); + } + + /** + * Resolve the given cache driver. + * + * @param string $driver + * @return \Illuminate\Cache\Store + */ + protected function cache($driver) + { + return call_user_func($this->cacheFactoryResolver)->driver($driver); } } diff --git a/src/Illuminate/Session/NullSessionHandler.php b/src/Illuminate/Session/NullSessionHandler.php index 56f567e7c12f4705776c27d3edfe0533c931c174..b9af93ab635fd6d6003e304190cb477a508cb0c4 100644 --- a/src/Illuminate/Session/NullSessionHandler.php +++ b/src/Illuminate/Session/NullSessionHandler.php @@ -8,7 +8,10 @@ class NullSessionHandler implements SessionHandlerInterface { /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function open($savePath, $sessionName) { return true; @@ -16,7 +19,10 @@ class NullSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function close() { return true; @@ -24,7 +30,10 @@ class NullSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return string|false */ + #[\ReturnTypeWillChange] public function read($sessionId) { return ''; @@ -32,7 +41,10 @@ class NullSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function write($sessionId, $data) { return true; @@ -40,7 +52,10 @@ class NullSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return bool */ + #[\ReturnTypeWillChange] public function destroy($sessionId) { return true; @@ -48,7 +63,10 @@ class NullSessionHandler implements SessionHandlerInterface /** * {@inheritdoc} + * + * @return int|false */ + #[\ReturnTypeWillChange] public function gc($lifetime) { return true; diff --git a/src/Illuminate/Session/SessionManager.php b/src/Illuminate/Session/SessionManager.php index cf5d6225ba056cb849ec0df70f931634bdb3dcbb..6d881fc222ba20195d252d62b7f39e71a1e6e21e 100755 --- a/src/Illuminate/Session/SessionManager.php +++ b/src/Illuminate/Session/SessionManager.php @@ -17,6 +17,16 @@ class SessionManager extends Manager return $this->buildSession(parent::callCustomCreator($driver)); } + /** + * Create an instance of the "null" session driver. + * + * @return \Illuminate\Session\Store + */ + protected function createNullDriver() + { + return $this->buildSession(new NullSessionHandler); + } + /** * Create an instance of the "array" session driver. * @@ -24,7 +34,9 @@ class SessionManager extends Manager */ protected function createArrayDriver() { - return $this->buildSession(new NullSessionHandler); + return $this->buildSession(new ArraySessionHandler( + $this->config->get('session.lifetime') + )); } /** @@ -190,6 +202,26 @@ class SessionManager extends Manager ); } + /** + * Determine if requests for the same session should wait for each to finish before executing. + * + * @return bool + */ + public function shouldBlock() + { + return $this->config->get('session.block', false); + } + + /** + * Get the name of the cache store / driver that should be used to acquire session locks. + * + * @return string|null + */ + public function blockDriver() + { + return $this->config->get('session.block_store'); + } + /** * Get the session configuration. * diff --git a/src/Illuminate/Session/SessionServiceProvider.php b/src/Illuminate/Session/SessionServiceProvider.php index 02363e3b63e2d907ca70c0f2099edefa3bc22354..d1108e3ae89f7af08834629a26193dcaeef960e6 100755 --- a/src/Illuminate/Session/SessionServiceProvider.php +++ b/src/Illuminate/Session/SessionServiceProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Session; +use Illuminate\Contracts\Cache\Factory as CacheFactory; use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\ServiceProvider; @@ -18,7 +19,11 @@ class SessionServiceProvider extends ServiceProvider $this->registerSessionDriver(); - $this->app->singleton(StartSession::class); + $this->app->singleton(StartSession::class, function ($app) { + return new StartSession($app->make(SessionManager::class), function () use ($app) { + return $app->make(CacheFactory::class); + }); + }); } /** diff --git a/src/Illuminate/Session/Store.php b/src/Illuminate/Session/Store.php index 7251d259d5cd80d394949e7c0f3729ac1a7fa18e..151e8b6330b562bbf83fae8818bb5ae85cf5e0b5 100755 --- a/src/Illuminate/Session/Store.php +++ b/src/Illuminate/Session/Store.php @@ -193,6 +193,17 @@ class Store implements Session }); } + /** + * Determine if the given key is missing from the session data. + * + * @param string|array $key + * @return bool + */ + public function missing($key) + { + return ! $this->exists($key); + } + /** * Checks if a key is present and not null. * @@ -222,7 +233,7 @@ class Store implements Session * Get the value of a given key and then forget it. * * @param string $key - * @param string|null $default + * @param mixed $default * @return mixed */ public function pull($key, $default = null) @@ -637,6 +648,16 @@ class Store implements Session $this->put('_previous.url', $url); } + /** + * Specify that the user has confirmed their password. + * + * @return void + */ + public function passwordConfirmed() + { + $this->put('auth.password_confirmed_at', time()); + } + /** * Get the underlying session handler implementation. * diff --git a/src/Illuminate/Session/composer.json b/src/Illuminate/Session/composer.json index 5707e43836c23f6af21cdfacce901da3f42cb842..dc1c3ea30d69d0e0cd970b46c15de0ceb47ad856 100755 --- a/src/Illuminate/Session/composer.json +++ b/src/Illuminate/Session/composer.json @@ -14,13 +14,14 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/contracts": "^6.0", - "illuminate/filesystem": "^6.0", - "illuminate/support": "^6.0", - "symfony/finder": "^4.3.4", - "symfony/http-foundation": "^4.3.4" + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/filesystem": "^8.0", + "illuminate/support": "^8.0", + "symfony/finder": "^5.4", + "symfony/http-foundation": "^5.4" }, "autoload": { "psr-4": { @@ -29,11 +30,11 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/console": "Required to use the session:table command (^6.0)." + "illuminate/console": "Required to use the session:table command (^8.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Support/Carbon.php b/src/Illuminate/Support/Carbon.php index 9383c3fd897da923aad69ab5c809a04eeee0eaa5..004b27b0751e3e22f57d0fe9d91bc9b6fcb0dbf5 100644 --- a/src/Illuminate/Support/Carbon.php +++ b/src/Illuminate/Support/Carbon.php @@ -3,8 +3,16 @@ namespace Illuminate\Support; use Carbon\Carbon as BaseCarbon; +use Carbon\CarbonImmutable as BaseCarbonImmutable; class Carbon extends BaseCarbon { - // + /** + * {@inheritdoc} + */ + public static function setTestNow($testNow = null) + { + BaseCarbon::setTestNow($testNow); + BaseCarbonImmutable::setTestNow($testNow); + } } diff --git a/src/Illuminate/Support/Composer.php b/src/Illuminate/Support/Composer.php index 7eca930cb3370a98fe97d60d2202ccb639eb4dcc..623e9163dce0420218557252a84d28529ceef346 100644 --- a/src/Illuminate/Support/Composer.php +++ b/src/Illuminate/Support/Composer.php @@ -39,7 +39,7 @@ class Composer * Regenerate the Composer autoloader files. * * @param string|array $extra - * @return void + * @return int */ public function dumpAutoloads($extra = '') { @@ -47,17 +47,17 @@ class Composer $command = array_merge($this->findComposer(), ['dump-autoload'], $extra); - $this->getProcess($command)->run(); + return $this->getProcess($command)->run(); } /** * Regenerate the optimized Composer autoloader files. * - * @return void + * @return int */ public function dumpOptimized() { - $this->dumpAutoloads('--optimize'); + return $this->dumpAutoloads('--optimize'); } /** diff --git a/src/Illuminate/Support/ConfigurationUrlParser.php b/src/Illuminate/Support/ConfigurationUrlParser.php index c7861d5c1c470f8dbad877223e8102254fe70100..be54b9a83d5b60ced1bb3e3aa42ae4da4b4287a1 100644 --- a/src/Illuminate/Support/ConfigurationUrlParser.php +++ b/src/Illuminate/Support/ConfigurationUrlParser.php @@ -33,9 +33,7 @@ class ConfigurationUrlParser $config = ['url' => $config]; } - $url = $config['url'] ?? null; - - $config = Arr::except($config, 'url'); + $url = Arr::pull($config, 'url'); if (! $url) { return $config; @@ -172,7 +170,7 @@ class ConfigurationUrlParser } /** - * Get all of the current drivers aliases. + * Get all of the current drivers' aliases. * * @return array */ diff --git a/src/Illuminate/Support/DateFactory.php b/src/Illuminate/Support/DateFactory.php index 72f22231dbf0da5a00b210533272b63bc42391df..f36cb46f312f1ad466be3bdd15a142715addbb72 100644 --- a/src/Illuminate/Support/DateFactory.php +++ b/src/Illuminate/Support/DateFactory.php @@ -64,7 +64,7 @@ use InvalidArgumentException; * @method static Carbon setHumanDiffOptions($humanDiffOptions) * @method static bool setLocale($locale) * @method static void setMidDayAt($hour) - * @method static Carbon setTestNow($testNow = null) + * @method static void setTestNow($testNow = null) * @method static void setToStringFormat($format) * @method static void setTranslator(\Symfony\Component\Translation\TranslatorInterface $translator) * @method static Carbon setUtf8($utf8) @@ -217,7 +217,7 @@ class DateFactory return $dateClass::$method(...$parameters); } - // If that fails, create the date with the default class.. + // If that fails, create the date with the default class... $date = $defaultClassName::$method(...$parameters); // If the configured class has an "instance" method, we'll try to pass our date into there... diff --git a/src/Illuminate/Support/Env.php b/src/Illuminate/Support/Env.php index 751e53845f651b2ca1617c4c848971cbdf411a66..b31007304065aa99fb60d5830820395a243ed891 100644 --- a/src/Illuminate/Support/Env.php +++ b/src/Illuminate/Support/Env.php @@ -2,10 +2,8 @@ namespace Illuminate\Support; -use Dotenv\Environment\Adapter\EnvConstAdapter; -use Dotenv\Environment\Adapter\PutenvAdapter; -use Dotenv\Environment\Adapter\ServerConstAdapter; -use Dotenv\Environment\DotenvFactory; +use Dotenv\Repository\Adapter\PutenvAdapter; +use Dotenv\Repository\RepositoryBuilder; use PhpOption\Option; class Env @@ -18,18 +16,11 @@ class Env protected static $putenv = true; /** - * The environment factory instance. + * The environment repository instance. * - * @var \Dotenv\Environment\FactoryInterface|null + * @var \Dotenv\Repository\RepositoryInterface|null */ - protected static $factory; - - /** - * The environment variables instance. - * - * @var \Dotenv\Environment\VariablesInterface|null - */ - protected static $variables; + protected static $repository; /** * Enable the putenv adapter. @@ -39,8 +30,7 @@ class Env public static function enablePutenv() { static::$putenv = true; - static::$factory = null; - static::$variables = null; + static::$repository = null; } /** @@ -51,41 +41,27 @@ class Env public static function disablePutenv() { static::$putenv = false; - static::$factory = null; - static::$variables = null; + static::$repository = null; } /** - * Get the environment factory instance. + * Get the environment repository instance. * - * @return \Dotenv\Environment\FactoryInterface + * @return \Dotenv\Repository\RepositoryInterface */ - public static function getFactory() + public static function getRepository() { - if (static::$factory === null) { - $adapters = array_merge( - [new EnvConstAdapter, new ServerConstAdapter], - static::$putenv ? [new PutenvAdapter] : [] - ); + if (static::$repository === null) { + $builder = RepositoryBuilder::createWithDefaultAdapters(); - static::$factory = new DotenvFactory($adapters); - } + if (static::$putenv) { + $builder = $builder->addAdapter(PutenvAdapter::class); + } - return static::$factory; - } - - /** - * Get the environment variables instance. - * - * @return \Dotenv\Environment\VariablesInterface - */ - public static function getVariables() - { - if (static::$variables === null) { - static::$variables = static::getFactory()->createImmutable(); + static::$repository = $builder->immutable()->make(); } - return static::$variables; + return static::$repository; } /** @@ -97,7 +73,7 @@ class Env */ public static function get($key, $default = null) { - return Option::fromValue(static::getVariables()->get($key)) + return Option::fromValue(static::getRepository()->get($key)) ->map(function ($value) { switch (strtolower($value)) { case 'true': diff --git a/src/Illuminate/Support/Facades/App.php b/src/Illuminate/Support/Facades/App.php index 67e0b4c233e44da5d1ef72c5d8fad671afc91bfb..8fbec3d4c084adcbd85dac4a44b964e3517518b0 100755 --- a/src/Illuminate/Support/Facades/App.php +++ b/src/Illuminate/Support/Facades/App.php @@ -3,43 +3,49 @@ namespace Illuminate\Support\Facades; /** - * @method static string version() - * @method static string basePath() - * @method static string bootstrapPath(string $path = '') - * @method static string configPath(string $path = '') - * @method static string databasePath(string $path = '') - * @method static string environmentPath() - * @method static string resourcePath(string $path = '') - * @method static string storagePath(string $path = '') - * @method static string|bool environment(string|array ...$environments) - * @method static bool runningInConsole() - * @method static bool runningUnitTests() - * @method static bool isDownForMaintenance() - * @method static void registerConfiguredProviders() + * @method static \Illuminate\Contracts\Foundation\Application loadEnvironmentFrom(string $file) * @method static \Illuminate\Support\ServiceProvider register(\Illuminate\Support\ServiceProvider|string $provider, bool $force = false) - * @method static void registerDeferredProvider(string $provider, string $service = null) * @method static \Illuminate\Support\ServiceProvider resolveProvider(string $provider) - * @method static void boot() - * @method static void booting(callable $callback) - * @method static void booted(callable $callback) - * @method static void bootstrapWith(array $bootstrappers) + * @method static array getProviders(\Illuminate\Support\ServiceProvider|string $provider) + * @method static mixed make($abstract, array $parameters = []) + * @method static mixed makeWith($abstract, array $parameters = []) * @method static bool configurationIsCached() + * @method static bool hasBeenBootstrapped() + * @method static bool isDownForMaintenance() + * @method static bool isLocal() + * @method static bool isProduction() + * @method static bool routesAreCached() + * @method static bool runningInConsole() + * @method static bool runningUnitTests() + * @method static bool shouldSkipMiddleware() + * @method static string basePath(string $path = '') + * @method static string bootstrapPath(string $path = '') + * @method static string configPath(string $path = '') + * @method static string databasePath(string $path = '') * @method static string detectEnvironment(callable $callback) * @method static string environmentFile() * @method static string environmentFilePath() + * @method static string environmentPath() * @method static string getCachedConfigPath() - * @method static string getCachedServicesPath() * @method static string getCachedPackagesPath() * @method static string getCachedRoutesPath() + * @method static string getCachedServicesPath() * @method static string getLocale() + * @method static string currentLocale() * @method static string getNamespace() - * @method static array getProviders(\Illuminate\Support\ServiceProvider|string $provider) - * @method static bool hasBeenBootstrapped() + * @method static string resourcePath(string $path = '') + * @method static string storagePath(string $path = '') + * @method static string version() + * @method static string|bool environment(string|array ...$environments) + * @method static never abort(int $code, string $message = '', array $headers = []) + * @method static void boot() + * @method static void booted(callable $callback) + * @method static void booting(callable $callback) + * @method static void bootstrapWith(array $bootstrappers) * @method static void loadDeferredProviders() - * @method static \Illuminate\Contracts\Foundation\Application loadEnvironmentFrom(string $file) - * @method static bool routesAreCached() + * @method static void registerConfiguredProviders() + * @method static void registerDeferredProvider(string $provider, string $service = null) * @method static void setLocale(string $locale) - * @method static bool shouldSkipMiddleware() * @method static void terminate() * * @see \Illuminate\Contracts\Foundation\Application diff --git a/src/Illuminate/Support/Facades/Artisan.php b/src/Illuminate/Support/Facades/Artisan.php index d4c7391b61d4e6cfd76d14613cdde984c2bdc249..e383c4908c4d5e4ba1212090bf4abf76ac0055fd 100755 --- a/src/Illuminate/Support/Facades/Artisan.php +++ b/src/Illuminate/Support/Facades/Artisan.php @@ -5,13 +5,13 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract; /** - * @method static int handle(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface|null $output = null) - * @method static int call(string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) * @method static \Illuminate\Foundation\Bus\PendingDispatch queue(string $command, array $parameters = []) + * @method static \Illuminate\Foundation\Console\ClosureCommand command(string $command, callable $callback) * @method static array all() + * @method static int call(string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) + * @method static int handle(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface|null $output = null) * @method static string output() * @method static void terminate(\Symfony\Component\Console\Input\InputInterface $input, int $status) - * @method static \Illuminate\Foundation\Console\ClosureCommand command(string $command, callable $callback) * * @see \Illuminate\Contracts\Console\Kernel */ diff --git a/src/Illuminate/Support/Facades/Auth.php b/src/Illuminate/Support/Facades/Auth.php index a035ced2e343ddd3db763ebdd47287cb4b12d381..3a2715c8411d250cc9ab2bf4cfe0675395bad69c 100755 --- a/src/Illuminate/Support/Facades/Auth.php +++ b/src/Illuminate/Support/Facades/Auth.php @@ -2,27 +2,32 @@ namespace Illuminate\Support\Facades; +use Laravel\Ui\UiServiceProvider; +use RuntimeException; + /** + * @method static \Illuminate\Auth\AuthManager extend(string $driver, \Closure $callback) + * @method static \Illuminate\Auth\AuthManager provider(string $name, \Closure $callback) + * @method static \Illuminate\Contracts\Auth\Authenticatable loginUsingId(mixed $id, bool $remember = false) + * @method static \Illuminate\Contracts\Auth\Authenticatable|null user() * @method static \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard guard(string|null $name = null) - * @method static void shouldUse(string $name); + * @method static \Illuminate\Contracts\Auth\UserProvider|null createUserProvider(string $provider = null) + * @method static \Symfony\Component\HttpFoundation\Response|null onceBasic(string $field = 'email',array $extraConditions = []) + * @method static bool attempt(array $credentials = [], bool $remember = false) + * @method static bool hasUser() * @method static bool check() * @method static bool guest() - * @method static \Illuminate\Contracts\Auth\Authenticatable|null user() - * @method static int|null id() - * @method static bool validate(array $credentials = []) - * @method static void setUser(\Illuminate\Contracts\Auth\Authenticatable $user) - * @method static bool attempt(array $credentials = [], bool $remember = false) * @method static bool once(array $credentials = []) - * @method static void login(\Illuminate\Contracts\Auth\Authenticatable $user, bool $remember = false) - * @method static \Illuminate\Contracts\Auth\Authenticatable loginUsingId(mixed $id, bool $remember = false) * @method static bool onceUsingId(mixed $id) + * @method static bool validate(array $credentials = []) * @method static bool viaRemember() - * @method static void logout() - * @method static \Symfony\Component\HttpFoundation\Response|null onceBasic(string $field = 'email',array $extraConditions = []) * @method static bool|null logoutOtherDevices(string $password, string $attribute = 'password') - * @method static \Illuminate\Contracts\Auth\UserProvider|null createUserProvider(string $provider = null) - * @method static \Illuminate\Auth\AuthManager extend(string $driver, \Closure $callback) - * @method static \Illuminate\Auth\AuthManager provider(string $name, \Closure $callback) + * @method static int|string|null id() + * @method static void login(\Illuminate\Contracts\Auth\Authenticatable $user, bool $remember = false) + * @method static void logout() + * @method static void logoutCurrentDevice() + * @method static void setUser(\Illuminate\Contracts\Auth\Authenticatable $user) + * @method static void shouldUse(string $name); * * @see \Illuminate\Auth\AuthManager * @see \Illuminate\Contracts\Auth\Factory @@ -46,9 +51,15 @@ class Auth extends Facade * * @param array $options * @return void + * + * @throws \RuntimeException */ public static function routes(array $options = []) { + if (! static::$app->providerIsLoaded(UiServiceProvider::class)) { + throw new RuntimeException('In order to use the Auth::routes() method, please install the laravel/ui package.'); + } + static::$app->make('router')->auth($options); } } diff --git a/src/Illuminate/Support/Facades/Blade.php b/src/Illuminate/Support/Facades/Blade.php index 003176c72f305b770fc7eaeccf3858dc19faaced..81019e288de64dc08f39bfbf8da87f72a66d8e75 100755 --- a/src/Illuminate/Support/Facades/Blade.php +++ b/src/Illuminate/Support/Facades/Blade.php @@ -3,22 +3,32 @@ namespace Illuminate\Support\Facades; /** - * @method static void compile(string|null $path = null) - * @method static string getPath() - * @method static void setPath(string $path) + * @method static array getClassComponentAliases() + * @method static array getCustomDirectives() + * @method static array getExtensions() + * @method static bool check(string $name, array ...$parameters) * @method static string compileString(string $value) + * @method static string render(string $string, array $data = [], bool $deleteCachedView = false) + * @method static string renderComponent(\Illuminate\View\Component $component) + * @method static string getPath() * @method static string stripParentheses(string $expression) + * @method static void aliasComponent(string $path, string|null $alias = null) + * @method static void aliasInclude(string $path, string|null $alias = null) + * @method static void compile(string|null $path = null) + * @method static void component(string $class, string|null $alias = null, string $prefix = '') + * @method static void components(array $components, string $prefix = '') + * @method static void componentNamespace(string $namespace, string $prefix) + * @method static void directive(string $name, callable $handler) * @method static void extend(callable $compiler) - * @method static array getExtensions() * @method static void if(string $name, callable $callback) - * @method static bool check(string $name, array ...$parameters) - * @method static void component(string $path, string|null $alias = null) * @method static void include(string $path, string|null $alias = null) - * @method static void directive(string $name, callable $handler) - * @method static array getCustomDirectives() + * @method static void precompiler(callable $precompiler) * @method static void setEchoFormat(string $format) + * @method static void setPath(string $path) * @method static void withDoubleEncoding() + * @method static void withoutComponentTags() * @method static void withoutDoubleEncoding() + * @method static void stringable(string|callable $class, callable|null $handler = null) * * @see \Illuminate\View\Compilers\BladeCompiler */ diff --git a/src/Illuminate/Support/Facades/Broadcast.php b/src/Illuminate/Support/Facades/Broadcast.php index 23e9e0f145ad1ca517a144a195ab24910aafcdf0..5dd79ca06c0030c361ac59003d90d97102ee1c71 100644 --- a/src/Illuminate/Support/Facades/Broadcast.php +++ b/src/Illuminate/Support/Facades/Broadcast.php @@ -5,10 +5,11 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactoryContract; /** - * @method static void connection($name = null); * @method static \Illuminate\Broadcasting\Broadcasters\Broadcaster channel(string $channel, callable|string $callback, array $options = []) * @method static mixed auth(\Illuminate\Http\Request $request) - * @method static void routes() + * @method static \Illuminate\Contracts\Broadcasting\Broadcaster connection($name = null); + * @method static void routes(array $attributes = null) + * @method static \Illuminate\Broadcasting\BroadcastManager socket($request = null) * * @see \Illuminate\Contracts\Broadcasting\Factory */ diff --git a/src/Illuminate/Support/Facades/Bus.php b/src/Illuminate/Support/Facades/Bus.php index a678bff7f99266fc829de684c375286abd70f72d..f0c22cb3f34df6f266389fc3bc1d5117fe91bd05 100644 --- a/src/Illuminate/Support/Facades/Bus.php +++ b/src/Illuminate/Support/Facades/Bus.php @@ -3,18 +3,33 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Illuminate\Foundation\Bus\PendingChain; use Illuminate\Support\Testing\Fakes\BusFake; /** - * @method static mixed dispatch($command) - * @method static mixed dispatchNow($command, $handler = null) + * @method static \Illuminate\Bus\Batch|null findBatch(string $batchId) + * @method static \Illuminate\Bus\PendingBatch batch(array|mixed $jobs) + * @method static \Illuminate\Contracts\Bus\Dispatcher map(array $map) + * @method static \Illuminate\Contracts\Bus\Dispatcher pipeThrough(array $pipes) + * @method static \Illuminate\Foundation\Bus\PendingChain chain(array $jobs) * @method static bool hasCommandHandler($command) * @method static bool|mixed getCommandHandler($command) - * @method static \Illuminate\Contracts\Bus\Dispatcher pipeThrough(array $pipes) - * @method static \Illuminate\Contracts\Bus\Dispatcher map(array $map) - * @method static void assertDispatched(string $command, callable|int $callback = null) + * @method static mixed dispatch($command) + * @method static mixed dispatchNow($command, $handler = null) + * @method static mixed dispatchSync($command, $handler = null) + * @method static void assertDispatched(string|\Closure $command, callable|int $callback = null) * @method static void assertDispatchedTimes(string $command, int $times = 1) - * @method static void assertNotDispatched(string $command, callable|int $callback = null) + * @method static void assertNotDispatched(string|\Closure $command, callable|int $callback = null) + * @method static void assertDispatchedAfterResponse(string|\Closure $command, callable|int $callback = null) + * @method static void assertDispatchedAfterResponseTimes(string $command, int $times = 1) + * @method static void assertNotDispatchedAfterResponse(string|\Closure $command, callable $callback = null) + * @method static void assertBatched(callable $callback) + * @method static void assertBatchCount(int $count) + * @method static void assertChained(array $expectedChain) + * @method static void assertDispatchedSync(string|\Closure $command, callable $callback = null) + * @method static void assertDispatchedSyncTimes(string $command, int $times = 1) + * @method static void assertNotDispatchedSync(string|\Closure $command, callable $callback = null) + * @method static void assertDispatchedWithoutChain(string|\Closure $command, callable $callback = null) * * @see \Illuminate\Contracts\Bus\Dispatcher */ @@ -33,6 +48,20 @@ class Bus extends Facade return $fake; } + /** + * Dispatch the given chain of jobs. + * + * @param array|mixed $jobs + * @return \Illuminate\Foundation\Bus\PendingDispatch + */ + public static function dispatchChain($jobs) + { + $jobs = is_array($jobs) ? $jobs : func_get_args(); + + return (new PendingChain(array_shift($jobs), $jobs)) + ->dispatch(); + } + /** * Get the registered name of the component. * diff --git a/src/Illuminate/Support/Facades/Cache.php b/src/Illuminate/Support/Facades/Cache.php index 26e584ce7e1ed764879fdc3af966f13d92dc0820..70aa1dc48395977e93bc5f8d4cc1f3458988f450 100755 --- a/src/Illuminate/Support/Facades/Cache.php +++ b/src/Illuminate/Support/Facades/Cache.php @@ -3,21 +3,25 @@ namespace Illuminate\Support\Facades; /** + * @method static \Illuminate\Cache\TaggedCache tags(array|mixed $names) + * @method static \Illuminate\Contracts\Cache\Lock lock(string $name, int $seconds = 0, mixed $owner = null) + * @method static \Illuminate\Contracts\Cache\Lock restoreLock(string $name, string $owner) * @method static \Illuminate\Contracts\Cache\Repository store(string|null $name = null) + * @method static \Illuminate\Contracts\Cache\Store getStore() + * @method static bool add(string $key, $value, \DateTimeInterface|\DateInterval|int $ttl = null) + * @method static bool flush() + * @method static bool forever(string $key, $value) + * @method static bool forget(string $key) * @method static bool has(string $key) * @method static bool missing(string $key) + * @method static bool put(string $key, $value, \DateTimeInterface|\DateInterval|int $ttl = null) + * @method static int|bool decrement(string $key, $value = 1) + * @method static int|bool increment(string $key, $value = 1) * @method static mixed get(string $key, mixed $default = null) * @method static mixed pull(string $key, mixed $default = null) - * @method static bool put(string $key, $value, \DateTimeInterface|\DateInterval|int $ttl) - * @method static bool add(string $key, $value, \DateTimeInterface|\DateInterval|int $ttl) - * @method static int|bool increment(string $key, $value = 1) - * @method static int|bool decrement(string $key, $value = 1) - * @method static bool forever(string $key, $value) * @method static mixed remember(string $key, \DateTimeInterface|\DateInterval|int $ttl, \Closure $callback) - * @method static mixed sear(string $key, \Closure $callback) * @method static mixed rememberForever(string $key, \Closure $callback) - * @method static bool forget(string $key) - * @method static \Illuminate\Contracts\Cache\Store getStore() + * @method static mixed sear(string $key, \Closure $callback) * * @see \Illuminate\Cache\CacheManager * @see \Illuminate\Cache\Repository diff --git a/src/Illuminate/Support/Facades/Config.php b/src/Illuminate/Support/Facades/Config.php index e1fa74abca1cbf1cc00bc69e39741e27091478df..a66256c99a15ed3b06fb18db8f1160abb06f387b 100755 --- a/src/Illuminate/Support/Facades/Config.php +++ b/src/Illuminate/Support/Facades/Config.php @@ -3,12 +3,12 @@ namespace Illuminate\Support\Facades; /** + * @method static array all() * @method static bool has($key) * @method static mixed get($key, $default = null) - * @method static array all() - * @method static void set($key, $value = null) * @method static void prepend($key, $value) * @method static void push($key, $value) + * @method static void set($key, $value = null) * * @see \Illuminate\Config\Repository */ diff --git a/src/Illuminate/Support/Facades/Cookie.php b/src/Illuminate/Support/Facades/Cookie.php index 245ed1ec71b3303178272d69aa4ffd43ebb599c2..cd1fe668b8980dada2e7efef224ed919f33f0ccf 100755 --- a/src/Illuminate/Support/Facades/Cookie.php +++ b/src/Illuminate/Support/Facades/Cookie.php @@ -3,9 +3,9 @@ namespace Illuminate\Support\Facades; /** - * @method static void queue(...$parameters) - * @method static unqueue($name) * @method static array getQueuedCookies() + * @method static unqueue($name) + * @method static void queue(...$parameters) * * @see \Illuminate\Cookie\CookieJar */ diff --git a/src/Illuminate/Support/Facades/Crypt.php b/src/Illuminate/Support/Facades/Crypt.php index 20f269d9b416350ca2211be56c771a9af1d863be..61eaaa83e3fb6c40ac3b3a6f6329f1e4134f27bf 100755 --- a/src/Illuminate/Support/Facades/Crypt.php +++ b/src/Illuminate/Support/Facades/Crypt.php @@ -4,11 +4,11 @@ namespace Illuminate\Support\Facades; /** * @method static bool supported(string $key, string $cipher) - * @method static string generateKey(string $cipher) - * @method static string encrypt(mixed $value, bool $serialize = true) - * @method static string encryptString(string $value) * @method static mixed decrypt(string $payload, bool $unserialize = true) * @method static string decryptString(string $payload) + * @method static string encrypt(mixed $value, bool $serialize = true) + * @method static string encryptString(string $value) + * @method static string generateKey(string $cipher) * @method static string getKey() * * @see \Illuminate\Encryption\Encrypter diff --git a/src/Illuminate/Support/Facades/DB.php b/src/Illuminate/Support/Facades/DB.php index a249b4a099b4f718dec5fc29e4c41290b72f6374..554dd22030ff919e569b3fd861f71e6cac69b9bc 100755 --- a/src/Illuminate/Support/Facades/DB.php +++ b/src/Illuminate/Support/Facades/DB.php @@ -3,27 +3,35 @@ namespace Illuminate\Support\Facades; /** + * @method static \Doctrine\DBAL\Driver\PDOConnection getPdo() * @method static \Illuminate\Database\ConnectionInterface connection(string $name = null) - * @method static string getDefaultConnection() - * @method static void setDefaultConnection(string $name) - * @method static \Illuminate\Database\Query\Builder table(string $table) + * @method static \Illuminate\Database\Query\Builder table(string $table, string $as = null) * @method static \Illuminate\Database\Query\Expression raw($value) - * @method static mixed selectOne(string $query, array $bindings = []) - * @method static array select(string $query, array $bindings = []) + * @method static array getQueryLog() + * @method static array prepareBindings(array $bindings) + * @method static array pretend(\Closure $callback) + * @method static array select(string $query, array $bindings = [], bool $useReadPdo = true) * @method static bool insert(string $query, array $bindings = []) - * @method static int update(string $query, array $bindings = []) - * @method static int delete(string $query, array $bindings = []) + * @method static bool logging() * @method static bool statement(string $query, array $bindings = []) - * @method static int affectingStatement(string $query, array $bindings = []) * @method static bool unprepared(string $query) - * @method static array prepareBindings(array $bindings) + * @method static int affectingStatement(string $query, array $bindings = []) + * @method static int delete(string $query, array $bindings = []) + * @method static int transactionLevel() + * @method static int update(string $query, array $bindings = []) + * @method static mixed selectOne(string $query, array $bindings = [], bool $useReadPdo = true) * @method static mixed transaction(\Closure $callback, int $attempts = 1) + * @method static string getDefaultConnection() + * @method static void afterCommit(\Closure $callback) * @method static void beginTransaction() * @method static void commit() - * @method static void rollBack() - * @method static int transactionLevel() - * @method static array pretend(\Closure $callback) + * @method static void enableQueryLog() + * @method static void disableQueryLog() + * @method static void flushQueryLog() + * @method static \Illuminate\Database\Connection beforeExecuting(\Closure $callback) * @method static void listen(\Closure $callback) + * @method static void rollBack(int $toLevel = null) + * @method static void setDefaultConnection(string $name) * * @see \Illuminate\Database\DatabaseManager * @see \Illuminate\Database\Connection diff --git a/src/Illuminate/Support/Facades/Date.php b/src/Illuminate/Support/Facades/Date.php index 650e7a2d390bbebef977338176e08080a9eda511..34102aec7753a8060e2d92e28211ab92666d639f 100644 --- a/src/Illuminate/Support/Facades/Date.php +++ b/src/Illuminate/Support/Facades/Date.php @@ -10,80 +10,80 @@ use Illuminate\Support\DateFactory; * * @method static \Illuminate\Support\Carbon create($year = 0, $month = 1, $day = 1, $hour = 0, $minute = 0, $second = 0, $tz = null) * @method static \Illuminate\Support\Carbon createFromDate($year = null, $month = null, $day = null, $tz = null) - * @method static \Illuminate\Support\Carbon|false createFromFormat($format, $time, $tz = null) * @method static \Illuminate\Support\Carbon createFromTime($hour = 0, $minute = 0, $second = 0, $tz = null) * @method static \Illuminate\Support\Carbon createFromTimeString($time, $tz = null) * @method static \Illuminate\Support\Carbon createFromTimestamp($timestamp, $tz = null) * @method static \Illuminate\Support\Carbon createFromTimestampMs($timestamp, $tz = null) * @method static \Illuminate\Support\Carbon createFromTimestampUTC($timestamp) * @method static \Illuminate\Support\Carbon createMidnightDate($year = null, $month = null, $day = null, $tz = null) - * @method static \Illuminate\Support\Carbon|false createSafe($year = null, $month = null, $day = null, $hour = null, $minute = null, $second = null, $tz = null) * @method static \Illuminate\Support\Carbon disableHumanDiffOption($humanDiffOption) * @method static \Illuminate\Support\Carbon enableHumanDiffOption($humanDiffOption) - * @method static mixed executeWithLocale($locale, $func) * @method static \Illuminate\Support\Carbon fromSerialized($value) - * @method static array getAvailableLocales() - * @method static array getDays() - * @method static int getHumanDiffOptions() - * @method static array getIsoUnits() * @method static \Illuminate\Support\Carbon getLastErrors() - * @method static string getLocale() - * @method static int getMidDayAt() * @method static \Illuminate\Support\Carbon getTestNow() + * @method static \Illuminate\Support\Carbon instance($date) + * @method static \Illuminate\Support\Carbon isMutable() + * @method static \Illuminate\Support\Carbon maxValue() + * @method static \Illuminate\Support\Carbon minValue() + * @method static \Illuminate\Support\Carbon now($tz = null) + * @method static \Illuminate\Support\Carbon parse($time = null, $tz = null) + * @method static \Illuminate\Support\Carbon setHumanDiffOptions($humanDiffOptions) + * @method static void setTestNow($testNow = null) + * @method static \Illuminate\Support\Carbon setUtf8($utf8) + * @method static \Illuminate\Support\Carbon today($tz = null) + * @method static \Illuminate\Support\Carbon tomorrow($tz = null) + * @method static \Illuminate\Support\Carbon useStrictMode($strictModeEnabled = true) + * @method static \Illuminate\Support\Carbon yesterday($tz = null) + * @method static \Illuminate\Support\Carbon|false createFromFormat($format, $time, $tz = null) + * @method static \Illuminate\Support\Carbon|false createSafe($year = null, $month = null, $day = null, $hour = null, $minute = null, $second = null, $tz = null) + * @method static \Illuminate\Support\Carbon|null make($var) * @method static \Symfony\Component\Translation\TranslatorInterface getTranslator() - * @method static int getWeekEndsAt() - * @method static int getWeekStartsAt() + * @method static array getAvailableLocales() + * @method static array getDays() + * @method static array getIsoUnits() * @method static array getWeekendDays() * @method static bool hasFormat($date, $format) * @method static bool hasMacro($name) * @method static bool hasRelativeKeywords($time) * @method static bool hasTestNow() - * @method static \Illuminate\Support\Carbon instance($date) * @method static bool isImmutable() * @method static bool isModifiableUnit($unit) - * @method static \Illuminate\Support\Carbon isMutable() * @method static bool isStrictModeEnabled() * @method static bool localeHasDiffOneDayWords($locale) * @method static bool localeHasDiffSyntax($locale) * @method static bool localeHasDiffTwoDayWords($locale) * @method static bool localeHasPeriodSyntax($locale) * @method static bool localeHasShortUnits($locale) + * @method static bool setLocale($locale) + * @method static bool shouldOverflowMonths() + * @method static bool shouldOverflowYears() + * @method static int getHumanDiffOptions() + * @method static int getMidDayAt() + * @method static int getWeekEndsAt() + * @method static int getWeekStartsAt() + * @method static mixed executeWithLocale($locale, $func) + * @method static mixed use(mixed $handler) + * @method static string getLocale() + * @method static string pluralUnit(string $unit) + * @method static string singularUnit(string $unit) * @method static void macro($name, $macro) - * @method static \Illuminate\Support\Carbon|null make($var) - * @method static \Illuminate\Support\Carbon maxValue() - * @method static \Illuminate\Support\Carbon minValue() * @method static void mixin($mixin) - * @method static \Illuminate\Support\Carbon now($tz = null) - * @method static \Illuminate\Support\Carbon parse($time = null, $tz = null) - * @method static string pluralUnit(string $unit) * @method static void resetMonthsOverflow() * @method static void resetToStringFormat() * @method static void resetYearsOverflow() * @method static void serializeUsing($callback) - * @method static \Illuminate\Support\Carbon setHumanDiffOptions($humanDiffOptions) - * @method static bool setLocale($locale) * @method static void setMidDayAt($hour) - * @method static \Illuminate\Support\Carbon setTestNow($testNow = null) * @method static void setToStringFormat($format) * @method static void setTranslator(\Symfony\Component\Translation\TranslatorInterface $translator) - * @method static \Illuminate\Support\Carbon setUtf8($utf8) * @method static void setWeekEndsAt($day) * @method static void setWeekStartsAt($day) * @method static void setWeekendDays($days) - * @method static bool shouldOverflowMonths() - * @method static bool shouldOverflowYears() - * @method static string singularUnit(string $unit) - * @method static \Illuminate\Support\Carbon today($tz = null) - * @method static \Illuminate\Support\Carbon tomorrow($tz = null) - * @method static mixed use(mixed $handler) * @method static void useCallable(callable $callable) * @method static void useClass(string $class) - * @method static void useFactory(object $factory) * @method static void useDefault() + * @method static void useFactory(object $factory) * @method static void useMonthsOverflow($monthsOverflow = true) - * @method static \Illuminate\Support\Carbon useStrictMode($strictModeEnabled = true) * @method static void useYearsOverflow($yearsOverflow = true) - * @method static \Illuminate\Support\Carbon yesterday($tz = null) */ class Date extends Facade { diff --git a/src/Illuminate/Support/Facades/Event.php b/src/Illuminate/Support/Facades/Event.php index 32f1743ea70b2d21f30f85de93e168035da37416..02f26f95d9bff7074ca97101a1d9293e75259565 100755 --- a/src/Illuminate/Support/Facades/Event.php +++ b/src/Illuminate/Support/Facades/Event.php @@ -6,22 +6,24 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Testing\Fakes\EventFake; /** - * @method static void listen(string|array $events, \Closure|string $listener) + * @method static \Closure createClassListener(string $listener, bool $wildcard = false) + * @method static \Closure makeListener(\Closure|string $listener, bool $wildcard = false) + * @method static \Illuminate\Events\Dispatcher setQueueResolver(callable $resolver) + * @method static array getListeners(string $eventName) + * @method static array|null dispatch(string|object $event, mixed $payload = [], bool $halt = false) + * @method static array|null until(string|object $event, mixed $payload = []) * @method static bool hasListeners(string $eventName) - * @method static void push(string $event, array $payload = []) + * @method static void assertDispatched(string|\Closure $event, callable|int $callback = null) + * @method static void assertDispatchedTimes(string $event, int $times = 1) + * @method static void assertNotDispatched(string|\Closure $event, callable|int $callback = null) + * @method static void assertNothingDispatched() + * @method static void assertListening(string $expectedEvent, string $expectedListener) * @method static void flush(string $event) - * @method static void subscribe(object|string $subscriber) - * @method static array|null until(string|object $event, mixed $payload = []) - * @method static array|null dispatch(string|object $event, mixed $payload = [], bool $halt = false) - * @method static array getListeners(string $eventName) - * @method static \Closure makeListener(\Closure|string $listener, bool $wildcard = false) - * @method static \Closure createClassListener(string $listener, bool $wildcard = false) * @method static void forget(string $event) * @method static void forgetPushed() - * @method static \Illuminate\Events\Dispatcher setQueueResolver(callable $resolver) - * @method static void assertDispatched(string $event, callable|int $callback = null) - * @method static void assertDispatchedTimes(string $event, int $times = 1) - * @method static void assertNotDispatched(string $event, callable|int $callback = null) + * @method static void listen(\Closure|string|array $events, \Closure|string|array $listener = null) + * @method static void push(string $event, array $payload = []) + * @method static void subscribe(object|string $subscriber) * * @see \Illuminate\Events\Dispatcher */ @@ -43,12 +45,27 @@ class Event extends Facade return $fake; } + /** + * Replace the bound instance with a fake that fakes all events except the given events. + * + * @param string[]|string $eventsToAllow + * @return \Illuminate\Support\Testing\Fakes\EventFake + */ + public static function fakeExcept($eventsToAllow) + { + return static::fake([ + function ($eventName) use ($eventsToAllow) { + return ! in_array($eventName, (array) $eventsToAllow); + }, + ]); + } + /** * Replace the bound instance with a fake during the given callable's execution. * * @param callable $callable * @param array $eventsToFake - * @return callable + * @return mixed */ public static function fakeFor(callable $callable, array $eventsToFake = []) { @@ -64,6 +81,27 @@ class Event extends Facade }); } + /** + * Replace the bound instance with a fake during the given callable's execution. + * + * @param callable $callable + * @param array $eventsToAllow + * @return mixed + */ + public static function fakeExceptFor(callable $callable, array $eventsToAllow = []) + { + $originalDispatcher = static::getFacadeRoot(); + + static::fakeExcept($eventsToAllow); + + return tap($callable(), function () use ($originalDispatcher) { + static::swap($originalDispatcher); + + Model::setEventDispatcher($originalDispatcher); + Cache::refreshEventDispatcher(); + }); + } + /** * Get the registered name of the component. * diff --git a/src/Illuminate/Support/Facades/Facade.php b/src/Illuminate/Support/Facades/Facade.php index 64c39b43340e4ad685dd6f195d0b73c2760749fb..befe902d095db127134d8caa765848716a0a10d4 100755 --- a/src/Illuminate/Support/Facades/Facade.php +++ b/src/Illuminate/Support/Facades/Facade.php @@ -4,7 +4,7 @@ namespace Illuminate\Support\Facades; use Closure; use Mockery; -use Mockery\MockInterface; +use Mockery\LegacyMockInterface; use RuntimeException; abstract class Facade @@ -126,7 +126,7 @@ abstract class Facade $name = static::getFacadeAccessor(); return isset(static::$resolvedInstance[$name]) && - static::$resolvedInstance[$name] instanceof MockInterface; + static::$resolvedInstance[$name] instanceof LegacyMockInterface; } /** diff --git a/src/Illuminate/Support/Facades/File.php b/src/Illuminate/Support/Facades/File.php index c23a1dc6f58a574c5bd767bde5f92f8f5b17d8a8..c22d363a8555902c2df9d794e6f567fd105e3b3e 100755 --- a/src/Illuminate/Support/Facades/File.php +++ b/src/Illuminate/Support/Facades/File.php @@ -3,44 +3,48 @@ namespace Illuminate\Support\Facades; /** + * @method static \Symfony\Component\Finder\SplFileInfo[] allFiles(string $directory, bool $hidden = false) + * @method static \Symfony\Component\Finder\SplFileInfo[] files(string $directory, bool $hidden = false) + * @method static array directories(string $directory) + * @method static array glob(string $pattern, int $flags = 0) + * @method static bool cleanDirectory(string $directory) + * @method static bool copy(string $path, string $target) + * @method static bool copyDirectory(string $directory, string $destination, int|null $options = null) + * @method static bool delete(string|array $paths) + * @method static bool deleteDirectories(string $directory) + * @method static bool deleteDirectory(string $directory, bool $preserve = false) * @method static bool exists(string $path) - * @method static string get(string $path, bool $lock = false) - * @method static string sharedGet(string $path) - * @method static mixed getRequire(string $path) - * @method static mixed requireOnce(string $file) - * @method static string hash(string $path) - * @method static int|bool put(string $path, string $contents, bool $lock = false) - * @method static void replace(string $path, string $content) - * @method static int prepend(string $path, string $data) + * @method static bool isDirectory(string $directory) + * @method static bool isFile(string $file) + * @method static bool isReadable(string $path) + * @method static bool isWritable(string $path) + * @method static bool makeDirectory(string $path, int $mode = 0755, bool $recursive = false, bool $force = false) + * @method static bool missing(string $path) + * @method static bool move(string $path, string $target) + * @method static bool moveDirectory(string $from, string $to, bool $overwrite = false) * @method static int append(string $path, string $data) + * @method static int lastModified(string $path) + * @method static int prepend(string $path, string $data) + * @method static int size(string $path) + * @method static int|bool put(string $path, string $contents, bool $lock = false) * @method static mixed chmod(string $path, int|null $mode = null) - * @method static bool delete(string|array $paths) - * @method static bool move(string $path, string $target) - * @method static bool copy(string $path, string $target) - * @method static void link(string $target, string $link) - * @method static string name(string $path) + * @method static mixed getRequire(string $path, array $data = []) + * @method static mixed requireOnce(string $file, array $data = []) * @method static string basename(string $path) * @method static string dirname(string $path) * @method static string extension(string $path) + * @method static string get(string $path, bool $lock = false) + * @method static string hash(string $path) + * @method static string name(string $path) + * @method static string sharedGet(string $path) * @method static string type(string $path) * @method static string|false mimeType(string $path) - * @method static int size(string $path) - * @method static int lastModified(string $path) - * @method static bool isDirectory(string $directory) - * @method static bool isReadable(string $path) - * @method static bool isWritable(string $path) - * @method static bool isFile(string $file) - * @method static array glob(string $pattern, int $flags = 0) - * @method static \Symfony\Component\Finder\SplFileInfo[] files(string $directory, bool $hidden = false) - * @method static \Symfony\Component\Finder\SplFileInfo[] allFiles(string $directory, bool $hidden = false) - * @method static array directories(string $directory) + * @method static string|null guessExtension(string $path) * @method static void ensureDirectoryExists(string $path, int $mode = 0755, bool $recursive = true) - * @method static bool makeDirectory(string $path, int $mode = 0755, bool $recursive = false, bool $force = false) - * @method static bool moveDirectory(string $from, string $to, bool $overwrite = false) - * @method static bool copyDirectory(string $directory, string $destination, int|null $options = null) - * @method static bool deleteDirectory(string $directory, bool $preserve = false) - * @method static bool deleteDirectories(string $directory) - * @method static bool cleanDirectory(string $directory) + * @method static void link(string $target, string $link) + * @method static \Illuminate\Support\LazyCollection lines(string $path) + * @method static void relativeLink(string $target, string $link) + * @method static void replace(string $path, string $content) * * @see \Illuminate\Filesystem\Filesystem */ diff --git a/src/Illuminate/Support/Facades/Gate.php b/src/Illuminate/Support/Facades/Gate.php index 0c172e2851337c3e9a2cef8b9584fc7149a1c0cd..49d8b66d6a7f1914d24264c6f3d48284a906e595 100644 --- a/src/Illuminate/Support/Facades/Gate.php +++ b/src/Illuminate/Support/Facades/Gate.php @@ -5,22 +5,24 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Auth\Access\Gate as GateContract; /** - * @method static bool has(string $ability) + * @method static \Illuminate\Auth\Access\Gate guessPolicyNamesUsing(callable $callback) + * @method static \Illuminate\Auth\Access\Response authorize(string $ability, array|mixed $arguments = []) + * @method static \Illuminate\Auth\Access\Response inspect(string $ability, array|mixed $arguments = []) + * @method static \Illuminate\Auth\Access\Response allowIf(\Closure|bool $condition, string|null $message = null, mixed $code = null) + * @method static \Illuminate\Auth\Access\Response denyIf(\Closure|bool $condition, string|null $message = null, mixed $code = null) + * @method static \Illuminate\Contracts\Auth\Access\Gate after(callable $callback) + * @method static \Illuminate\Contracts\Auth\Access\Gate before(callable $callback) * @method static \Illuminate\Contracts\Auth\Access\Gate define(string $ability, callable|string $callback) + * @method static \Illuminate\Contracts\Auth\Access\Gate forUser(\Illuminate\Contracts\Auth\Authenticatable|mixed $user) * @method static \Illuminate\Contracts\Auth\Access\Gate policy(string $class, string $policy) - * @method static \Illuminate\Contracts\Auth\Access\Gate before(callable $callback) - * @method static \Illuminate\Contracts\Auth\Access\Gate after(callable $callback) + * @method static array abilities() * @method static bool allows(string $ability, array|mixed $arguments = []) - * @method static bool denies(string $ability, array|mixed $arguments = []) - * @method static bool check(iterable|string $abilities, array|mixed $arguments = []) * @method static bool any(iterable|string $abilities, array|mixed $arguments = []) - * @method static \Illuminate\Auth\Access\Response authorize(string $ability, array|mixed $arguments = []) - * @method static mixed raw(string $ability, array|mixed $arguments = []) + * @method static bool check(iterable|string $abilities, array|mixed $arguments = []) + * @method static bool denies(string $ability, array|mixed $arguments = []) + * @method static bool has(string $ability) * @method static mixed getPolicyFor(object|string $class) - * @method static \Illuminate\Contracts\Auth\Access\Gate forUser(\Illuminate\Contracts\Auth\Authenticatable|mixed $user) - * @method static array abilities() - * @method static \Illuminate\Auth\Access\Response inspect(string $ability, array|mixed $arguments = []) - * @method static \Illuminate\Auth\Access\Gate guessPolicyNamesUsing(callable $callback) + * @method static mixed raw(string $ability, array|mixed $arguments = []) * * @see \Illuminate\Contracts\Auth\Access\Gate */ diff --git a/src/Illuminate/Support/Facades/Hash.php b/src/Illuminate/Support/Facades/Hash.php index 70cf07c4e5cfec5e5bd274dd6290c96ffb545c02..b9855fdd749ccf31ce18f9fceaa81a895931986a 100755 --- a/src/Illuminate/Support/Facades/Hash.php +++ b/src/Illuminate/Support/Facades/Hash.php @@ -4,9 +4,10 @@ namespace Illuminate\Support\Facades; /** * @method static array info(string $hashedValue) - * @method static string make(string $value, array $options = []) * @method static bool check(string $value, string $hashedValue, array $options = []) * @method static bool needsRehash(string $hashedValue, array $options = []) + * @method static string make(string $value, array $options = []) + * @method static \Illuminate\Hashing\HashManager extend($driver, \Closure $callback) * * @see \Illuminate\Hashing\HashManager */ diff --git a/src/Illuminate/Support/Facades/Http.php b/src/Illuminate/Support/Facades/Http.php new file mode 100644 index 0000000000000000000000000000000000000000..d6f2f6648578da20b90de8edadbd058fabd347ed --- /dev/null +++ b/src/Illuminate/Support/Facades/Http.php @@ -0,0 +1,67 @@ +<?php + +namespace Illuminate\Support\Facades; + +use Illuminate\Http\Client\Factory; + +/** + * @method static \GuzzleHttp\Promise\PromiseInterface response($body = null, $status = 200, $headers = []) + * @method static \Illuminate\Http\Client\Factory fake($callback = null) + * @method static \Illuminate\Http\Client\PendingRequest accept(string $contentType) + * @method static \Illuminate\Http\Client\PendingRequest acceptJson() + * @method static \Illuminate\Http\Client\PendingRequest asForm() + * @method static \Illuminate\Http\Client\PendingRequest asJson() + * @method static \Illuminate\Http\Client\PendingRequest asMultipart() + * @method static \Illuminate\Http\Client\PendingRequest async() + * @method static \Illuminate\Http\Client\PendingRequest attach(string|array $name, string $contents = '', string|null $filename = null, array $headers = []) + * @method static \Illuminate\Http\Client\PendingRequest baseUrl(string $url) + * @method static \Illuminate\Http\Client\PendingRequest beforeSending(callable $callback) + * @method static \Illuminate\Http\Client\PendingRequest bodyFormat(string $format) + * @method static \Illuminate\Http\Client\PendingRequest contentType(string $contentType) + * @method static \Illuminate\Http\Client\PendingRequest dd() + * @method static \Illuminate\Http\Client\PendingRequest dump() + * @method static \Illuminate\Http\Client\PendingRequest retry(int $times, int $sleep = 0, ?callable $when = null) + * @method static \Illuminate\Http\Client\PendingRequest sink(string|resource $to) + * @method static \Illuminate\Http\Client\PendingRequest stub(callable $callback) + * @method static \Illuminate\Http\Client\PendingRequest timeout(int $seconds) + * @method static \Illuminate\Http\Client\PendingRequest withBasicAuth(string $username, string $password) + * @method static \Illuminate\Http\Client\PendingRequest withBody(resource|string $content, string $contentType) + * @method static \Illuminate\Http\Client\PendingRequest withCookies(array $cookies, string $domain) + * @method static \Illuminate\Http\Client\PendingRequest withDigestAuth(string $username, string $password) + * @method static \Illuminate\Http\Client\PendingRequest withHeaders(array $headers) + * @method static \Illuminate\Http\Client\PendingRequest withMiddleware(callable $middleware) + * @method static \Illuminate\Http\Client\PendingRequest withOptions(array $options) + * @method static \Illuminate\Http\Client\PendingRequest withToken(string $token, string $type = 'Bearer') + * @method static \Illuminate\Http\Client\PendingRequest withUserAgent(string $userAgent) + * @method static \Illuminate\Http\Client\PendingRequest withoutRedirecting() + * @method static \Illuminate\Http\Client\PendingRequest withoutVerifying() + * @method static array pool(callable $callback) + * @method static \Illuminate\Http\Client\Response delete(string $url, array $data = []) + * @method static \Illuminate\Http\Client\Response get(string $url, array|string|null $query = null) + * @method static \Illuminate\Http\Client\Response head(string $url, array|string|null $query = null) + * @method static \Illuminate\Http\Client\Response patch(string $url, array $data = []) + * @method static \Illuminate\Http\Client\Response post(string $url, array $data = []) + * @method static \Illuminate\Http\Client\Response put(string $url, array $data = []) + * @method static \Illuminate\Http\Client\Response send(string $method, string $url, array $options = []) + * @method static \Illuminate\Http\Client\ResponseSequence fakeSequence(string $urlPattern = '*') + * @method static void assertSent(callable $callback) + * @method static void assertSentInOrder(array $callbacks) + * @method static void assertNotSent(callable $callback) + * @method static void assertNothingSent() + * @method static void assertSentCount(int $count) + * @method static void assertSequencesAreEmpty() + * + * @see \Illuminate\Http\Client\Factory + */ +class Http extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return Factory::class; + } +} diff --git a/src/Illuminate/Support/Facades/Lang.php b/src/Illuminate/Support/Facades/Lang.php index 4222e04531c712c33f29c4a0781ff2031eeca3f3..3e7ece4d2aa33db7c3b339c4688ccac80d24f07a 100755 --- a/src/Illuminate/Support/Facades/Lang.php +++ b/src/Illuminate/Support/Facades/Lang.php @@ -3,6 +3,8 @@ namespace Illuminate\Support\Facades; /** + * @method static bool hasForLocale(string $key, string $locale = null) + * @method static bool has(string $key, string $locale = null, bool $fallback = true) * @method static mixed get(string $key, array $replace = [], string $locale = null, bool $fallback = true) * @method static string choice(string $key, \Countable|int|array $number, array $replace = [], string $locale = null) * @method static string getLocale() diff --git a/src/Illuminate/Support/Facades/Log.php b/src/Illuminate/Support/Facades/Log.php index 149adc6f868741c473e1e4efae86dacf1fba3f45..68493fd22af2e977e520745727e4fb53005d63ca 100755 --- a/src/Illuminate/Support/Facades/Log.php +++ b/src/Illuminate/Support/Facades/Log.php @@ -3,17 +3,22 @@ namespace Illuminate\Support\Facades; /** - * @method static void emergency(string $message, array $context = []) + * @method static \Psr\Log\LoggerInterface channel(string $channel = null) + * @method static \Psr\Log\LoggerInterface stack(array $channels, string $channel = null) + * @method static \Psr\Log\LoggerInterface build(array $config) + * @method static \Illuminate\Log\Logger withContext(array $context = []) + * @method static \Illuminate\Log\Logger withoutContext() * @method static void alert(string $message, array $context = []) * @method static void critical(string $message, array $context = []) + * @method static void debug(string $message, array $context = []) + * @method static void emergency(string $message, array $context = []) * @method static void error(string $message, array $context = []) - * @method static void warning(string $message, array $context = []) - * @method static void notice(string $message, array $context = []) * @method static void info(string $message, array $context = []) - * @method static void debug(string $message, array $context = []) * @method static void log($level, string $message, array $context = []) - * @method static \Psr\Log\LoggerInterface channel(string $channel = null) - * @method static \Psr\Log\LoggerInterface stack(array $channels, string $channel = null) + * @method static void notice(string $message, array $context = []) + * @method static void warning(string $message, array $context = []) + * @method static void write(string $level, string $message, array $context = []) + * @method static void listen(\Closure $callback) * * @see \Illuminate\Log\Logger */ diff --git a/src/Illuminate/Support/Facades/Mail.php b/src/Illuminate/Support/Facades/Mail.php index 4cf5aba8a499d3805f54afb7b1305315991d9d20..103884c477eeb0d8b332001408b3cad5d25fcc7f 100755 --- a/src/Illuminate/Support/Facades/Mail.php +++ b/src/Illuminate/Support/Facades/Mail.php @@ -5,23 +5,32 @@ namespace Illuminate\Support\Facades; use Illuminate\Support\Testing\Fakes\MailFake; /** - * @method static \Illuminate\Mail\PendingMail to($users) + * @method static \Illuminate\Mail\Mailer mailer(string|null $name = null) + * @method static void alwaysFrom(string $address, string|null $name = null) + * @method static void alwaysReplyTo(string $address, string|null $name = null) + * @method static void alwaysReturnPath(string $address) + * @method static void alwaysTo(string $address, string|null $name = null) * @method static \Illuminate\Mail\PendingMail bcc($users) - * @method static void raw(string $text, $callback) - * @method static void send(\Illuminate\Contracts\Mail\Mailable|string|array $view, array $data = [], \Closure|string $callback = null) + * @method static \Illuminate\Mail\PendingMail to($users) + * @method static \Illuminate\Support\Collection queued(string $mailable, \Closure|string $callback = null) + * @method static \Illuminate\Support\Collection sent(string $mailable, \Closure|string $callback = null) * @method static array failures() - * @method static mixed queue(\Illuminate\Contracts\Mail\Mailable|string|array $view, string $queue = null) + * @method static bool hasQueued(string $mailable) + * @method static bool hasSent(string $mailable) * @method static mixed later(\DateTimeInterface|\DateInterval|int $delay, \Illuminate\Contracts\Mail\Mailable|string|array $view, string $queue = null) - * @method static void assertSent(string $mailable, callable|int $callback = null) - * @method static void assertNotSent(string $mailable, callable|int $callback = null) - * @method static void assertNothingSent() - * @method static void assertQueued(string $mailable, callable|int $callback = null) + * @method static mixed laterOn(string $queue, \DateTimeInterface|\DateInterval|int $delay, \Illuminate\Contracts\Mail\Mailable|string|array $view) + * @method static mixed queue(\Illuminate\Contracts\Mail\Mailable|string|array $view, string $queue = null) + * @method static mixed queueOn(string $queue, \Illuminate\Contracts\Mail\Mailable|string|array $view) * @method static void assertNotQueued(string $mailable, callable $callback = null) + * @method static void assertNotSent(string $mailable, callable|int $callback = null) * @method static void assertNothingQueued() - * @method static \Illuminate\Support\Collection sent(string $mailable, \Closure|string $callback = null) - * @method static bool hasSent(string $mailable) - * @method static \Illuminate\Support\Collection queued(string $mailable, \Closure|string $callback = null) - * @method static bool hasQueued(string $mailable) + * @method static void assertNothingSent() + * @method static void assertQueued(string|\Closure $mailable, callable|int $callback = null) + * @method static void assertSent(string|\Closure $mailable, callable|int $callback = null) + * @method static void raw(string $text, $callback) + * @method static void plain(string $view, array $data, $callback) + * @method static void html(string $html, $callback) + * @method static void send(\Illuminate\Contracts\Mail\Mailable|string|array $view, array $data = [], \Closure|string $callback = null) * * @see \Illuminate\Mail\Mailer * @see \Illuminate\Support\Testing\Fakes\MailFake @@ -47,6 +56,6 @@ class Mail extends Facade */ protected static function getFacadeAccessor() { - return 'mailer'; + return 'mail.manager'; } } diff --git a/src/Illuminate/Support/Facades/Notification.php b/src/Illuminate/Support/Facades/Notification.php index 3af8e715f52e49387a696b87855d8b4c3d38386f..e16a9cceaf7a2a9ffeb25fdfcb722026966939c1 100644 --- a/src/Illuminate/Support/Facades/Notification.php +++ b/src/Illuminate/Support/Facades/Notification.php @@ -7,17 +7,19 @@ use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Testing\Fakes\NotificationFake; /** - * @method static void send(\Illuminate\Support\Collection|array|mixed $notifiables, $notification) - * @method static void sendNow(\Illuminate\Support\Collection|array|mixed $notifiables, $notification) - * @method static mixed channel(string|null $name = null) * @method static \Illuminate\Notifications\ChannelManager locale(string|null $locale) - * @method static void assertSentTo(mixed $notifiable, string $notification, callable $callback = null) - * @method static void assertSentToTimes(mixed $notifiable, string $notification, int $times = 1) - * @method static void assertNotSentTo(mixed $notifiable, string $notification, callable $callback = null) - * @method static void assertNothingSent() - * @method static void assertTimesSent(int $expectedCount, string $notification) * @method static \Illuminate\Support\Collection sent(mixed $notifiable, string $notification, callable $callback = null) * @method static bool hasSent(mixed $notifiable, string $notification) + * @method static mixed channel(string|null $name = null) + * @method static void assertNotSentTo(mixed $notifiable, string|\Closure $notification, callable $callback = null) + * @method static void assertNothingSent() + * @method static void assertSentOnDemand(string|\Closure $notification, callable $callback = null) + * @method static void assertSentTo(mixed $notifiable, string|\Closure $notification, callable $callback = null) + * @method static void assertSentOnDemandTimes(string $notification, int $times = 1) + * @method static void assertSentToTimes(mixed $notifiable, string $notification, int $times = 1) + * @method static void assertTimesSent(int $expectedCount, string $notification) + * @method static void send(\Illuminate\Support\Collection|array|mixed $notifiables, $notification) + * @method static void sendNow(\Illuminate\Support\Collection|array|mixed $notifiables, $notification) * * @see \Illuminate\Notifications\ChannelManager */ diff --git a/src/Illuminate/Support/Facades/ParallelTesting.php b/src/Illuminate/Support/Facades/ParallelTesting.php new file mode 100644 index 0000000000000000000000000000000000000000..c3976113501f7fbae05f99e9946c7a84771f4c23 --- /dev/null +++ b/src/Illuminate/Support/Facades/ParallelTesting.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Support\Facades; + +/** + * @method static void setUpProcess(callable $callback) + * @method static void setUpTestCase(callable $callback) + * @method static void setUpTestDatabase(callable $callback) + * @method static void tearDownProcess(callable $callback) + * @method static void tearDownTestCase(callable $callback) + * @method static int|false token() + * + * @see \Illuminate\Testing\ParallelTesting + */ +class ParallelTesting extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return \Illuminate\Testing\ParallelTesting::class; + } +} diff --git a/src/Illuminate/Support/Facades/Password.php b/src/Illuminate/Support/Facades/Password.php index 864b5e987dde7a65c4c766409945b2cd3546b0a0..4ff371f9cece9778c1a34efc755b75daf50e7505 100755 --- a/src/Illuminate/Support/Facades/Password.php +++ b/src/Illuminate/Support/Facades/Password.php @@ -5,8 +5,14 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Auth\PasswordBroker; /** - * @method static string sendResetLink(array $credentials) * @method static mixed reset(array $credentials, \Closure $callback) + * @method static string sendResetLink(array $credentials, \Closure $callback = null) + * @method static \Illuminate\Contracts\Auth\CanResetPassword getUser(array $credentials) + * @method static string createToken(\Illuminate\Contracts\Auth\CanResetPassword $user) + * @method static void deleteToken(\Illuminate\Contracts\Auth\CanResetPassword $user) + * @method static bool tokenExists(\Illuminate\Contracts\Auth\CanResetPassword $user, string $token) + * @method static \Illuminate\Auth\Passwords\TokenRepositoryInterface getRepository() + * @method static \Illuminate\Contracts\Auth\PasswordBroker broker(string|null $name = null) * * @see \Illuminate\Auth\Passwords\PasswordBroker */ diff --git a/src/Illuminate/Support/Facades/Queue.php b/src/Illuminate/Support/Facades/Queue.php index d1ca3568d2e22b3364f4cb15af605b790c296dda..5af1329e0356183dee6e77fa23fae805c2c8a39e 100755 --- a/src/Illuminate/Support/Facades/Queue.php +++ b/src/Illuminate/Support/Facades/Queue.php @@ -2,23 +2,24 @@ namespace Illuminate\Support\Facades; +use Illuminate\Queue\Worker; use Illuminate\Support\Testing\Fakes\QueueFake; /** + * @method static \Illuminate\Contracts\Queue\Job|null pop(string $queue = null) + * @method static \Illuminate\Contracts\Queue\Queue setConnectionName(string $name) * @method static int size(string $queue = null) + * @method static mixed bulk(array $jobs, mixed $data = '', string $queue = null) + * @method static mixed later(\DateTimeInterface|\DateInterval|int $delay, string|object $job, mixed $data = '', string $queue = null) + * @method static mixed laterOn(string $queue, \DateTimeInterface|\DateInterval|int $delay, string|object $job, mixed $data = '') * @method static mixed push(string|object $job, mixed $data = '', $queue = null) * @method static mixed pushOn(string $queue, string|object $job, mixed $data = '') * @method static mixed pushRaw(string $payload, string $queue = null, array $options = []) - * @method static mixed later(\DateTimeInterface|\DateInterval|int $delay, string|object $job, mixed $data = '', string $queue = null) - * @method static mixed laterOn(string $queue, \DateTimeInterface|\DateInterval|int $delay, string|object $job, mixed $data = '') - * @method static mixed bulk(array $jobs, mixed $data = '', string $queue = null) - * @method static \Illuminate\Contracts\Queue\Job|null pop(string $queue = null) * @method static string getConnectionName() - * @method static \Illuminate\Contracts\Queue\Queue setConnectionName(string $name) + * @method static void assertNotPushed(string|\Closure $job, callable $callback = null) * @method static void assertNothingPushed() - * @method static void assertNotPushed(string $job, callable $callback = null) - * @method static void assertPushed(string $job, callable|int $callback = null) - * @method static void assertPushedOn(string $queue, string $job, callable|int $callback = null) + * @method static void assertPushed(string|\Closure $job, callable|int $callback = null) + * @method static void assertPushedOn(string $queue, string|\Closure $job, callable $callback = null) * @method static void assertPushedWithChain(string $job, array $expectedChain = [], callable $callback = null) * * @see \Illuminate\Queue\QueueManager @@ -26,6 +27,18 @@ use Illuminate\Support\Testing\Fakes\QueueFake; */ class Queue extends Facade { + /** + * Register a callback to be executed to pick jobs. + * + * @param string $workerName + * @param callable $callback + * @return void + */ + public static function popUsing($workerName, $callback) + { + return Worker::popUsing($workerName, $callback); + } + /** * Replace the bound instance with a fake. * diff --git a/src/Illuminate/Support/Facades/RateLimiter.php b/src/Illuminate/Support/Facades/RateLimiter.php new file mode 100644 index 0000000000000000000000000000000000000000..5cfd78462fc28c21b838934360fe1e167eefe4d0 --- /dev/null +++ b/src/Illuminate/Support/Facades/RateLimiter.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Support\Facades; + +/** + * @method static \Illuminate\Cache\RateLimiter for(string $name, \Closure $callback) + * @method static \Closure limiter(string $name) + * @method static bool tooManyAttempts($key, $maxAttempts) + * @method static int hit($key, $decaySeconds = 60) + * @method static mixed attempts($key) + * @method static mixed resetAttempts($key) + * @method static int retriesLeft($key, $maxAttempts) + * @method static void clear($key) + * @method static int availableIn($key) + * @method static bool attempt($key, $maxAttempts, \Closure $callback, $decaySeconds = 60) + * + * @see \Illuminate\Cache\RateLimiter + */ +class RateLimiter extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return 'Illuminate\Cache\RateLimiter'; + } +} diff --git a/src/Illuminate/Support/Facades/Redirect.php b/src/Illuminate/Support/Facades/Redirect.php index 8bfcfebc232369f2c10386744378f26bf141892e..c8569b394eff0d0dc3ae517dba1bf39e3b4316f0 100755 --- a/src/Illuminate/Support/Facades/Redirect.php +++ b/src/Illuminate/Support/Facades/Redirect.php @@ -3,18 +3,21 @@ namespace Illuminate\Support\Facades; /** - * @method static \Illuminate\Http\RedirectResponse home(int $status = 302) + * @method static \Illuminate\Http\RedirectResponse action(string $action, mixed $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse away(string $path, int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse back(int $status = 302, array $headers = [], $fallback = false) - * @method static \Illuminate\Http\RedirectResponse refresh(int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse guest(string $path, int $status = 302, array $headers = [], bool $secure = null) + * @method static \Illuminate\Http\RedirectResponse home(int $status = 302) * @method static \Illuminate\Http\RedirectResponse intended(string $default = '/', int $status = 302, array $headers = [], bool $secure = null) - * @method static \Illuminate\Http\RedirectResponse to(string $path, int $status = 302, array $headers = [], bool $secure = null) - * @method static \Illuminate\Http\RedirectResponse away(string $path, int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse refresh(int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse route(string $route, mixed $parameters = [], int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse secure(string $path, int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse route(string $route, array $parameters = [], int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse action(string $action, array $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse signedRoute(string $name, mixed $parameters = [], \DateTimeInterface|\DateInterval|int $expiration = null, int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse temporarySignedRoute(string $name, \DateTimeInterface|\DateInterval|int $expiration, mixed $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse to(string $path, int $status = 302, array $headers = [], bool $secure = null) * @method static \Illuminate\Routing\UrlGenerator getUrlGenerator() * @method static void setSession(\Illuminate\Session\Store $session) + * @method static void setIntendedUrl(string $url) * * @see \Illuminate\Routing\Redirector */ diff --git a/src/Illuminate/Support/Facades/Request.php b/src/Illuminate/Support/Facades/Request.php index 81d0b4be0837d56176724882fa3d4e192ac377a8..05496d9ccd5dc693ab2f562d4eb311fc5522414d 100755 --- a/src/Illuminate/Support/Facades/Request.php +++ b/src/Illuminate/Support/Facades/Request.php @@ -3,89 +3,89 @@ namespace Illuminate\Support\Facades; /** - * @method static bool matchesType(string $actual, string $type) - * @method static bool isJson() - * @method static bool expectsJson() - * @method static bool wantsJson() + * @method static \Closure getRouteResolver() + * @method static \Closure getUserResolver() + * @method static \Illuminate\Http\Request capture() + * @method static \Illuminate\Http\Request createFrom(\Illuminate\Http\Request $from, \Illuminate\Http\Request|null $to = null) + * @method static \Illuminate\Http\Request createFromBase(\Symfony\Component\HttpFoundation\Request $request) + * @method static \Illuminate\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) + * @method static \Illuminate\Http\Request instance() + * @method static \Illuminate\Http\Request merge(array $input) + * @method static \Illuminate\Http\Request replace(array $input) + * @method static \Illuminate\Http\Request setJson(\Symfony\Component\HttpFoundation\ParameterBag $json) + * @method static \Illuminate\Http\Request setRouteResolver(\Closure $callback) + * @method static \Illuminate\Http\Request setUserResolver(\Closure $callback) + * @method static \Illuminate\Http\UploadedFile|\Illuminate\Http\UploadedFile[]|array|null file(string|null $key = null, mixed $default = null) + * @method static \Illuminate\Routing\Route|object|string route(string|null $param = null, string|null $default = null) + * @method static \Illuminate\Session\Store session() + * @method static \Illuminate\Session\Store|null getSession() + * @method static \Symfony\Component\HttpFoundation\ParameterBag|mixed json(string|null $key = null, mixed $default = null) + * @method static array all(array|mixed|null $keys = null) + * @method static array allFiles() + * @method static array except(array|mixed $keys) + * @method static array ips() + * @method static array keys() + * @method static array only(array|mixed $keys) + * @method static array segments() + * @method static array toArray() + * @method static array validate(array $rules, ...$params) + * @method static array validateWithBag(string $errorBag, array $rules, ...$params) * @method static bool accepts(string|array $contentTypes) - * @method static bool prefers(string|array $contentTypes) * @method static bool acceptsAnyContentType() - * @method static bool acceptsJson() * @method static bool acceptsHtml() - * @method static string format($default = 'html') - * @method static string|array old(string|null $key = null, string|array|null $default = null) - * @method static void flash() - * @method static void flashOnly(array|mixed $keys) - * @method static void flashExcept(array|mixed $keys) - * @method static void flush() - * @method static string|array|null server(string|null $key = null, string|array|null $default = null) - * @method static bool hasHeader(string $key) - * @method static string|array|null header(string|null $key = null, string|array|null $default = null) - * @method static string|null bearerToken() + * @method static bool acceptsJson() + * @method static bool ajax() + * @method static bool anyFilled(string|array $key) * @method static bool exists(string|array $key) + * @method static bool expectsJson() + * @method static bool filled(string|array $key) + * @method static bool fullUrlIs(mixed ...$patterns) * @method static bool has(string|array $key) * @method static bool hasAny(string|array $key) - * @method static bool filled(string|array $key) - * @method static bool anyFilled(string|array $key) - * @method static array keys() - * @method static array all(array|mixed|null $keys = null) - * @method static string|array|null input(string|null $key = null, string|array|null $default = null) - * @method static array only(array|mixed $keys) - * @method static array except(array|mixed $keys) - * @method static string|array|null query(string|null $key = null, string|array|null $default = null) - * @method static string|array|null post(string|null $key = null, string|array|null $default = null) * @method static bool hasCookie(string $key) - * @method static string|array|null cookie(string|null $key = null, string|array|null $default = null) - * @method static array allFiles() * @method static bool hasFile(string $key) - * @method static \Illuminate\Http\UploadedFile|\Illuminate\Http\UploadedFile[]|array|null file(string|null $key = null, mixed $default = null) - * @method static \Illuminate\Http\Request capture() - * @method static \Illuminate\Http\Request instance() - * @method static string method() - * @method static string root() - * @method static string url() - * @method static string fullUrl() - * @method static string fullUrlWithQuery(array $query) - * @method static string path() - * @method static string decodedPath() - * @method static string|null segment(int $index, string|null $default = null) - * @method static array segments() + * @method static bool hasHeader(string $key) + * @method static bool hasValidSignature(bool $absolute = true) * @method static bool is(mixed ...$patterns) - * @method static bool routeIs(mixed ...$patterns) - * @method static bool fullUrlIs(mixed ...$patterns) - * @method static bool ajax() + * @method static bool isJson() + * @method static bool matchesType(string $actual, string $type) + * @method static bool offsetExists(string $offset) * @method static bool pjax() + * @method static bool prefers(string|array $contentTypes) * @method static bool prefetch() + * @method static bool routeIs(mixed ...$patterns) * @method static bool secure() - * @method static string|null ip() - * @method static array ips() - * @method static string userAgent() - * @method static \Illuminate\Http\Request merge(array $input) - * @method static \Illuminate\Http\Request replace(array $input) - * @method static \Symfony\Component\HttpFoundation\ParameterBag|mixed json(string|null $key = null, mixed $default = null) - * @method static \Illuminate\Http\Request createFrom(\Illuminate\Http\Request $from, \Illuminate\Http\Request|null $to = null) - * @method static \Illuminate\Http\Request createFromBase(\Symfony\Component\HttpFoundation\Request $request) - * @method static \Illuminate\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) + * @method static bool wantsJson() * @method static mixed filterFiles(mixed $files) - * @method static \Illuminate\Session\Store session() - * @method static \Illuminate\Session\Store|null getSession() - * @method static void setLaravelSession(\Illuminate\Contracts\Session\Session $session) + * @method static mixed offsetGet(string $offset) * @method static mixed user(string|null $guard = null) - * @method static \Illuminate\Routing\Route|object|string route(string|null $param = null, string|null $default = null) + * @method static string decodedPath() * @method static string fingerprint() - * @method static \Illuminate\Http\Request setJson(\Symfony\Component\HttpFoundation\ParameterBag $json) - * @method static \Closure getUserResolver() - * @method static \Illuminate\Http\Request setUserResolver(\Closure $callback) - * @method static \Closure getRouteResolver() - * @method static \Illuminate\Http\Request setRouteResolver(\Closure $callback) - * @method static array toArray() - * @method static bool offsetExists(string $offset) - * @method static mixed offsetGet(string $offset) + * @method static string format($default = 'html') + * @method static string fullUrl() + * @method static string fullUrlWithQuery(array $query) + * @method static string method() + * @method static string path() + * @method static string root() + * @method static string url() + * @method static string userAgent() + * @method static string|array old(string|null $key = null, string|array|null $default = null) + * @method static string|array|null cookie(string|null $key = null, string|array|null $default = null) + * @method static string|array|null header(string|null $key = null, string|array|null $default = null) + * @method static string|array|null input(string|null $key = null, string|array|null $default = null) + * @method static string|array|null post(string|null $key = null, string|array|null $default = null) + * @method static string|array|null query(string|null $key = null, string|array|null $default = null) + * @method static string|array|null server(string|null $key = null, string|array|null $default = null) + * @method static string|null bearerToken() + * @method static string|null ip() + * @method static string|null segment(int $index, string|null $default = null) + * @method static void flash() + * @method static void flashExcept(array|mixed $keys) + * @method static void flashOnly(array|mixed $keys) + * @method static void flush() * @method static void offsetSet(string $offset, mixed $value) * @method static void offsetUnset(string $offset) - * @method static array validate(array $rules, ...$params) - * @method static array validateWithBag(string $errorBag, array $rules, ...$params) - * @method static bool hasValidSignature(bool $absolute = true) + * @method static void setLaravelSession(\Illuminate\Contracts\Session\Session $session) * * @see \Illuminate\Http\Request */ diff --git a/src/Illuminate/Support/Facades/Response.php b/src/Illuminate/Support/Facades/Response.php index ab8576b18bfcef57d3f58050d80435a715a5eb0a..6f319606ecb588d2967cffc30e05812000ff1389 100755 --- a/src/Illuminate/Support/Facades/Response.php +++ b/src/Illuminate/Support/Facades/Response.php @@ -5,20 +5,20 @@ namespace Illuminate\Support\Facades; use Illuminate\Contracts\Routing\ResponseFactory as ResponseFactoryContract; /** - * @method static \Illuminate\Http\Response make(string $content = '', int $status = 200, array $headers = []) - * @method static \Illuminate\Http\Response noContent($status = 204, array $headers = []) - * @method static \Illuminate\Http\Response view(string $view, array $data = [], int $status = 200, array $headers = []) * @method static \Illuminate\Http\JsonResponse json(string|array $data = [], int $status = 200, array $headers = [], int $options = 0) * @method static \Illuminate\Http\JsonResponse jsonp(string $callback, string|array $data = [], int $status = 200, array $headers = [], int $options = 0) - * @method static \Symfony\Component\HttpFoundation\StreamedResponse stream(\Closure $callback, int $status = 200, array $headers = []) - * @method static \Symfony\Component\HttpFoundation\StreamedResponse streamDownload(\Closure $callback, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') - * @method static \Symfony\Component\HttpFoundation\BinaryFileResponse download(\SplFileInfo|string $file, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') - * @method static \Symfony\Component\HttpFoundation\BinaryFileResponse file($file, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse redirectTo(string $path, int $status = 302, array $headers = [], bool|null $secure = null) - * @method static \Illuminate\Http\RedirectResponse redirectToRoute(string $route, array $parameters = [], int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse redirectToAction(string $action, array $parameters = [], int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse redirectGuest(string $path, int $status = 302, array $headers = [], bool|null $secure = null) + * @method static \Illuminate\Http\RedirectResponse redirectTo(string $path, int $status = 302, array $headers = [], bool|null $secure = null) + * @method static \Illuminate\Http\RedirectResponse redirectToAction(string $action, mixed $parameters = [], int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse redirectToIntended(string $default = '/', int $status = 302, array $headers = [], bool|null $secure = null) + * @method static \Illuminate\Http\RedirectResponse redirectToRoute(string $route, mixed $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\Response make(array|string $content = '', int $status = 200, array $headers = []) + * @method static \Illuminate\Http\Response noContent($status = 204, array $headers = []) + * @method static \Illuminate\Http\Response view(string $view, array $data = [], int $status = 200, array $headers = []) + * @method static \Symfony\Component\HttpFoundation\BinaryFileResponse download(\SplFileInfo|string $file, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') + * @method static \Symfony\Component\HttpFoundation\BinaryFileResponse file($file, array $headers = []) + * @method static \Symfony\Component\HttpFoundation\StreamedResponse stream(\Closure $callback, int $status = 200, array $headers = []) + * @method static \Symfony\Component\HttpFoundation\StreamedResponse streamDownload(\Closure $callback, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') * * @see \Illuminate\Contracts\Routing\ResponseFactory */ diff --git a/src/Illuminate/Support/Facades/Route.php b/src/Illuminate/Support/Facades/Route.php index 079f99e4765a441f05bc9d32ce0073c6230ad011..4a97cdd1940d513f13f035d32c277fafabb6c235 100755 --- a/src/Illuminate/Support/Facades/Route.php +++ b/src/Illuminate/Support/Facades/Route.php @@ -3,38 +3,48 @@ namespace Illuminate\Support\Facades; /** - * @method static \Illuminate\Routing\Route fallback(\Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route get(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route post(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route put(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route delete(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route patch(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route options(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route any(string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\Route match(array|string $methods, string $uri, \Closure|array|string|callable|null $action = null) - * @method static \Illuminate\Routing\RouteRegistrar prefix(string $prefix) - * @method static \Illuminate\Routing\RouteRegistrar where(array $where) - * @method static \Illuminate\Routing\PendingResourceRegistration resource(string $name, string $controller, array $options = []) - * @method static void resources(array $resources) - * @method static void pattern(string $key, string $pattern) * @method static \Illuminate\Routing\PendingResourceRegistration apiResource(string $name, string $controller, array $options = []) - * @method static void apiResources(array $resources, array $options = []) - * @method static \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) + * @method static \Illuminate\Routing\PendingResourceRegistration resource(string $name, string $controller, array $options = []) + * @method static \Illuminate\Routing\Route any(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route|null current() + * @method static \Illuminate\Routing\Route delete(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route fallback(array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route get(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route|null getCurrentRoute() + * @method static \Illuminate\Routing\RouteCollectionInterface getRoutes() + * @method static \Illuminate\Routing\Route match(array|string $methods, string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route options(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route patch(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route permanentRedirect(string $uri, string $destination) + * @method static \Illuminate\Routing\Route post(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route put(string $uri, array|string|callable|null $action = null) + * @method static \Illuminate\Routing\Route redirect(string $uri, string $destination, int $status = 302) * @method static \Illuminate\Routing\Route substituteBindings(\Illuminate\Support\Facades\Route $route) - * @method static void substituteImplicitBindings(\Illuminate\Support\Facades\Route $route) + * @method static \Illuminate\Routing\Route view(string $uri, string $view, array $data = [], int|array $status = 200, array $headers = []) * @method static \Illuminate\Routing\RouteRegistrar as(string $value) + * @method static \Illuminate\Routing\RouteRegistrar controller(string $controller) * @method static \Illuminate\Routing\RouteRegistrar domain(string $value) + * @method static \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) * @method static \Illuminate\Routing\RouteRegistrar name(string $value) - * @method static \Illuminate\Routing\RouteRegistrar namespace(string $value) + * @method static \Illuminate\Routing\RouteRegistrar namespace(string|null $value) + * @method static \Illuminate\Routing\RouteRegistrar prefix(string $prefix) + * @method static \Illuminate\Routing\RouteRegistrar scopeBindings() + * @method static \Illuminate\Routing\RouteRegistrar where(array $where) + * @method static \Illuminate\Routing\RouteRegistrar withoutMiddleware(array|string $middleware) * @method static \Illuminate\Routing\Router|\Illuminate\Routing\RouteRegistrar group(\Closure|string|array $attributes, \Closure|string $routes) - * @method static \Illuminate\Routing\Route redirect(string $uri, string $destination, int $status = 302) - * @method static \Illuminate\Routing\Route permanentRedirect(string $uri, string $destination) - * @method static \Illuminate\Routing\Route view(string $uri, string $view, array $data = []) + * @method static \Illuminate\Routing\ResourceRegistrar resourceVerbs(array $verbs = []) + * @method static string|null currentRouteAction() + * @method static string|null currentRouteName() + * @method static void apiResources(array $resources, array $options = []) * @method static void bind(string $key, string|callable $binder) * @method static void model(string $key, string $class, \Closure|null $callback = null) - * @method static \Illuminate\Routing\Route current() - * @method static string|null currentRouteName() - * @method static string|null currentRouteAction() + * @method static void pattern(string $key, string $pattern) + * @method static void resources(array $resources, array $options = []) + * @method static void substituteImplicitBindings(\Illuminate\Support\Facades\Route $route) + * @method static boolean uses(...$patterns) + * @method static boolean is(...$patterns) + * @method static boolean has(string $name) + * @method static mixed input(string $key, string|null $default = null) * * @see \Illuminate\Routing\Router */ diff --git a/src/Illuminate/Support/Facades/Schema.php b/src/Illuminate/Support/Facades/Schema.php index bb71a606cd0eb315bc4140b592fc6b1d49c39380..ffe59cb2626e19aa51003314fad4dd203d43122d 100755 --- a/src/Illuminate/Support/Facades/Schema.php +++ b/src/Illuminate/Support/Facades/Schema.php @@ -4,17 +4,25 @@ namespace Illuminate\Support\Facades; /** * @method static \Illuminate\Database\Schema\Builder create(string $table, \Closure $callback) + * @method static \Illuminate\Database\Schema\Builder createDatabase(string $name) + * @method static \Illuminate\Database\Schema\Builder disableForeignKeyConstraints() * @method static \Illuminate\Database\Schema\Builder drop(string $table) + * @method static \Illuminate\Database\Schema\Builder dropDatabaseIfExists(string $name) * @method static \Illuminate\Database\Schema\Builder dropIfExists(string $table) - * @method static \Illuminate\Database\Schema\Builder table(string $table, \Closure $callback) + * @method static \Illuminate\Database\Schema\Builder enableForeignKeyConstraints() * @method static \Illuminate\Database\Schema\Builder rename(string $from, string $to) - * @method static void defaultStringLength(int $length) - * @method static bool hasTable(string $table) + * @method static \Illuminate\Database\Schema\Builder table(string $table, \Closure $callback) * @method static bool hasColumn(string $table, string $column) * @method static bool hasColumns(string $table, array $columns) - * @method static \Illuminate\Database\Schema\Builder disableForeignKeyConstraints() - * @method static \Illuminate\Database\Schema\Builder enableForeignKeyConstraints() + * @method static bool dropColumns(string $table, array $columns) + * @method static bool hasTable(string $table) + * @method static void defaultStringLength(int $length) * @method static void registerCustomDoctrineType(string $class, string $name, string $type) + * @method static array getColumnListing(string $table) + * @method static string getColumnType(string $table, string $column) + * @method static void morphUsingUuids() + * @method static \Illuminate\Database\Connection getConnection() + * @method static \Illuminate\Database\Schema\Builder setConnection(\Illuminate\Database\Connection $connection) * * @see \Illuminate\Database\Schema\Builder */ diff --git a/src/Illuminate/Support/Facades/Session.php b/src/Illuminate/Support/Facades/Session.php index 63256520d8a8c6a8802e2a887845a0bf5a5ac653..a07232169129d8513a9b3e22e90797540214ec5a 100755 --- a/src/Illuminate/Support/Facades/Session.php +++ b/src/Illuminate/Support/Facades/Session.php @@ -3,29 +3,30 @@ namespace Illuminate\Support\Facades; /** - * @method static string getName() - * @method static string getId() - * @method static void setId(string $id) - * @method static bool start() - * @method static bool save() + * @method static \SessionHandlerInterface getHandler() * @method static array all() * @method static bool exists(string|array $key) + * @method static bool handlerNeedsRequest() * @method static bool has(string|array $key) + * @method static bool isStarted() + * @method static bool migrate(bool $destroy = false) + * @method static bool save() + * @method static bool start() * @method static mixed get(string $key, $default = null) + * @method static mixed flash(string $key, $value = true) * @method static mixed pull(string $key, $default = null) - * @method static void put(string|array $key, $value = null) - * @method static string token() * @method static mixed remove(string $key) - * @method static void forget(string|array $keys) - * @method static void flush() - * @method static bool migrate(bool $destroy = false) - * @method static bool isStarted() + * @method static string getId() + * @method static string getName() + * @method static string token() * @method static string|null previousUrl() + * @method static void flush() + * @method static void forget(string|array $keys) + * @method static void push(string $key, mixed $value) + * @method static void put(string|array $key, $value = null) + * @method static void setId(string $id) * @method static void setPreviousUrl(string $url) - * @method static \SessionHandlerInterface getHandler() - * @method static bool handlerNeedsRequest() * @method static void setRequestOnHandler(\Illuminate\Http\Request $request) - * @method static void push(string $key, mixed $value) * * @see \Illuminate\Session\SessionManager * @see \Illuminate\Session\Store diff --git a/src/Illuminate/Support/Facades/Storage.php b/src/Illuminate/Support/Facades/Storage.php index 1ead2bcf00da7809f7d9de9c5a908ef5ccca6f58..5a09125eec7b3a434c026ed6ccef06312835014a 100644 --- a/src/Illuminate/Support/Facades/Storage.php +++ b/src/Illuminate/Support/Facades/Storage.php @@ -5,31 +5,43 @@ namespace Illuminate\Support\Facades; use Illuminate\Filesystem\Filesystem; /** - * @method static \Illuminate\Contracts\Filesystem\Filesystem disk(string $name = null) - * @method static bool exists(string $path) - * @method static string get(string $path) - * @method static resource|null readStream(string $path) - * @method static bool put(string $path, string|resource $contents, mixed $options = []) - * @method static string|false putFile(string $path, \Illuminate\Http\File|\Illuminate\Http\UploadedFile|string $file, mixed $options = []) - * @method static string|false putFileAs(string $path, \Illuminate\Http\File|\Illuminate\Http\UploadedFile|string $file, string $name, mixed $options = []) - * @method static bool writeStream(string $path, resource $resource, array $options = []) - * @method static string getVisibility(string $path) - * @method static bool setVisibility(string $path, string $visibility) - * @method static bool prepend(string $path, string $data) + * @method static \Illuminate\Contracts\Filesystem\Filesystem assertExists(string|array $path) + * @method static \Illuminate\Contracts\Filesystem\Filesystem assertMissing(string|array $path) + * @method static \Illuminate\Contracts\Filesystem\Filesystem cloud() + * @method static \Illuminate\Contracts\Filesystem\Filesystem build(string|array $root) + * @method static \Illuminate\Contracts\Filesystem\Filesystem disk(string|null $name = null) + * @method static \Illuminate\Filesystem\FilesystemManager extend(string $driver, \Closure $callback) + * @method static \Symfony\Component\HttpFoundation\StreamedResponse download(string $path, string|null $name = null, array|null $headers = []) + * @method static \Symfony\Component\HttpFoundation\StreamedResponse response(string $path, string|null $name = null, array|null $headers = [], string|null $disposition = 'inline') + * @method static array allDirectories(string|null $directory = null) + * @method static array allFiles(string|null $directory = null) + * @method static array directories(string|null $directory = null, bool $recursive = false) + * @method static array files(string|null $directory = null, bool $recursive = false) * @method static bool append(string $path, string $data) - * @method static bool delete(string|array $paths) * @method static bool copy(string $from, string $to) + * @method static bool delete(string|array $paths) + * @method static bool deleteDirectory(string $directory) + * @method static bool exists(string $path) + * @method static bool makeDirectory(string $path) + * @method static bool missing(string $path) * @method static bool move(string $from, string $to) - * @method static int size(string $path) + * @method static bool prepend(string $path, string $data) + * @method static bool put(string $path, \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource $contents, mixed $options = []) + * @method static bool setVisibility(string $path, string $visibility) + * @method static bool writeStream(string $path, resource $resource, array $options = []) * @method static int lastModified(string $path) - * @method static array files(string|null $directory = null, bool $recursive = false) - * @method static array allFiles(string|null $directory = null) - * @method static array directories(string|null $directory = null, bool $recursive = false) - * @method static array allDirectories(string|null $directory = null) - * @method static bool makeDirectory(string $path) - * @method static bool deleteDirectory(string $directory) - * @method static \Illuminate\Contracts\Filesystem\Filesystem assertExists(string|array $path) - * @method static \Illuminate\Contracts\Filesystem\Filesystem assertMissing(string|array $path) + * @method static int size(string $path) + * @method static resource|null readStream(string $path) + * @method static string get(string $path) + * @method static string getVisibility(string $path) + * @method static string path(string $path) + * @method static string temporaryUrl(string $path, \DateTimeInterface $expiration, array $options = []) + * @method static string url(string $path) + * @method static string|false mimeType(string $path) + * @method static string|false putFile(string $path, \Illuminate\Http\File|\Illuminate\Http\UploadedFile|string $file, mixed $options = []) + * @method static string|false putFileAs(string $path, \Illuminate\Http\File|\Illuminate\Http\UploadedFile|string $file, string $name, mixed $options = []) + * @method static void macro(string $name, object|callable $macro) + * @method static void buildTemporaryUrlsUsing(\Closure $callback) * * @see \Illuminate\Filesystem\FilesystemManager */ @@ -46,9 +58,13 @@ class Storage extends Facade { $disk = $disk ?: static::$app['config']->get('filesystems.default'); - (new Filesystem)->cleanDirectory( - $root = storage_path('framework/testing/disks/'.$disk) - ); + $root = storage_path('framework/testing/disks/'.$disk); + + if ($token = ParallelTesting::token()) { + $root = "{$root}_test_{$token}"; + } + + (new Filesystem)->cleanDirectory($root); static::set($disk, $fake = static::createLocalDriver(array_merge($config, [ 'root' => $root, diff --git a/src/Illuminate/Support/Facades/URL.php b/src/Illuminate/Support/Facades/URL.php index 1ef545c11e101dee43995757d8df55de46d78e8a..7d9941d7c0274da38288c475454246562e3ee987 100755 --- a/src/Illuminate/Support/Facades/URL.php +++ b/src/Illuminate/Support/Facades/URL.php @@ -3,20 +3,24 @@ namespace Illuminate\Support\Facades; /** + * @method static \Illuminate\Contracts\Routing\UrlGenerator setRootControllerNamespace(string $rootNamespace) + * @method static bool hasValidSignature(\Illuminate\Http\Request $request, bool $absolute = true) + * @method static string action(string|array $action, $parameters = [], bool $absolute = true) + * @method static string asset(string $path, bool $secure = null) + * @method static string secureAsset(string $path) * @method static string current() * @method static string full() + * @method static void macro(string $name, object|callable $macro) + * @method static void mixin(object $mixin, bool $replace = true) * @method static string previous($fallback = false) - * @method static string to(string $path, $extra = [], bool $secure = null) - * @method static string secure(string $path, array $parameters = []) - * @method static string asset(string $path, bool $secure = null) * @method static string route(string $name, $parameters = [], bool $absolute = true) - * @method static string action(string $action, $parameters = [], bool $absolute = true) - * @method static \Illuminate\Contracts\Routing\UrlGenerator setRootControllerNamespace(string $rootNamespace) + * @method static string secure(string $path, array $parameters = []) * @method static string signedRoute(string $name, array $parameters = [], \DateTimeInterface|\DateInterval|int $expiration = null, bool $absolute = true) * @method static string temporarySignedRoute(string $name, \DateTimeInterface|\DateInterval|int $expiration, array $parameters = [], bool $absolute = true) - * @method static bool hasValidSignature(\Illuminate\Http\Request $request, bool $absolute = true) + * @method static string to(string $path, $extra = [], bool $secure = null) * @method static void defaults(array $defaults) * @method static void forceScheme(string $scheme) + * @method static bool isValidUrl(string $path) * * @see \Illuminate\Routing\UrlGenerator */ diff --git a/src/Illuminate/Support/Facades/Validator.php b/src/Illuminate/Support/Facades/Validator.php index 30f9f9b104d9ed0fcb4b8bda8ede8299ec319008..1087f4e3a0af0680c904448fb53265be8c6f4f67 100755 --- a/src/Illuminate/Support/Facades/Validator.php +++ b/src/Illuminate/Support/Facades/Validator.php @@ -4,9 +4,11 @@ namespace Illuminate\Support\Facades; /** * @method static \Illuminate\Contracts\Validation\Validator make(array $data, array $rules, array $messages = [], array $customAttributes = []) + * @method static void excludeUnvalidatedArrayKeys() * @method static void extend(string $rule, \Closure|string $extension, string $message = null) * @method static void extendImplicit(string $rule, \Closure|string $extension, string $message = null) * @method static void replacer(string $rule, \Closure|string $replacer) + * @method static array validate(array $data, array $rules, array $messages = [], array $customAttributes = []) * * @see \Illuminate\Validation\Factory */ diff --git a/src/Illuminate/Support/Facades/View.php b/src/Illuminate/Support/Facades/View.php index e7b16c19592b552dde69b9aa972c6dd41d47fec5..4988ee977eba50cbfd6ffd9f8c11a25abc3463a7 100755 --- a/src/Illuminate/Support/Facades/View.php +++ b/src/Illuminate/Support/Facades/View.php @@ -3,14 +3,16 @@ namespace Illuminate\Support\Facades; /** - * @method static bool exists(string $view) + * @method static \Illuminate\Contracts\View\Factory addNamespace(string $namespace, string|array $hints) + * @method static \Illuminate\Contracts\View\View first(array $views, \Illuminate\Contracts\Support\Arrayable|array $data = [], array $mergeData = []) + * @method static \Illuminate\Contracts\View\Factory replaceNamespace(string $namespace, string|array $hints) + * @method static \Illuminate\Contracts\View\Factory addExtension(string $extension, string $engine, \Closure|null $resolver = null) * @method static \Illuminate\Contracts\View\View file(string $path, array $data = [], array $mergeData = []) * @method static \Illuminate\Contracts\View\View make(string $view, array $data = [], array $mergeData = []) - * @method static mixed share(array|string $key, $value = null) * @method static array composer(array|string $views, \Closure|string $callback) * @method static array creator(array|string $views, \Closure|string $callback) - * @method static \Illuminate\Contracts\View\Factory addNamespace(string $namespace, string|array $hints) - * @method static \Illuminate\Contracts\View\Factory replaceNamespace(string $namespace, string|array $hints) + * @method static bool exists(string $view) + * @method static mixed share(array|string $key, $value = null) * * @see \Illuminate\View\Factory */ diff --git a/src/Illuminate/Support/Fluent.php b/src/Illuminate/Support/Fluent.php index e02fa9402b1183f939350619e017046b411ee8b9..4660283b87e6794ef1911087a1af2f485e0d9c21 100755 --- a/src/Illuminate/Support/Fluent.php +++ b/src/Illuminate/Support/Fluent.php @@ -70,6 +70,7 @@ class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); @@ -92,6 +93,7 @@ class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable * @param string $offset * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return isset($this->attributes[$offset]); @@ -103,6 +105,7 @@ class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable * @param string $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->get($offset); @@ -115,6 +118,7 @@ class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->attributes[$offset] = $value; @@ -126,6 +130,7 @@ class Fluent implements Arrayable, ArrayAccess, Jsonable, JsonSerializable * @param string $offset * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->attributes[$offset]); diff --git a/src/Illuminate/Support/HtmlString.php b/src/Illuminate/Support/HtmlString.php index c13adfd47ed7be04cfeb3e2755c4fb18e126040e..d6b71d46cde5169c90a01eece0261b3fd2528b8f 100644 --- a/src/Illuminate/Support/HtmlString.php +++ b/src/Illuminate/Support/HtmlString.php @@ -19,7 +19,7 @@ class HtmlString implements Htmlable * @param string $html * @return void */ - public function __construct($html) + public function __construct($html = '') { $this->html = $html; } @@ -34,6 +34,26 @@ class HtmlString implements Htmlable return $this->html; } + /** + * Determine if the given HTML string is empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->html === ''; + } + + /** + * Determine if the given HTML string is not empty. + * + * @return bool + */ + public function isNotEmpty() + { + return ! $this->isEmpty(); + } + /** * Get the HTML string. * diff --git a/src/Illuminate/Support/Js.php b/src/Illuminate/Support/Js.php new file mode 100644 index 0000000000000000000000000000000000000000..6d6de3440d71fb3016b8d6d89396e6d86feb8fe1 --- /dev/null +++ b/src/Illuminate/Support/Js.php @@ -0,0 +1,145 @@ +<?php + +namespace Illuminate\Support; + +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\Support\Jsonable; +use JsonSerializable; + +class Js implements Htmlable +{ + /** + * The JavaScript string. + * + * @var string + */ + protected $js; + + /** + * Flags that should be used when encoding to JSON. + * + * @var int + */ + protected const REQUIRED_FLAGS = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR; + + /** + * Create a new class instance. + * + * @param mixed $data + * @param int|null $flags + * @param int $depth + * @return void + * + * @throws \JsonException + */ + public function __construct($data, $flags = 0, $depth = 512) + { + $this->js = $this->convertDataToJavaScriptExpression($data, $flags, $depth); + } + + /** + * Create a new JavaScript string from the given data. + * + * @param mixed $data + * @param int $flags + * @param int $depth + * @return static + * + * @throws \JsonException + */ + public static function from($data, $flags = 0, $depth = 512) + { + return new static($data, $flags, $depth); + } + + /** + * Convert the given data to a JavaScript expression. + * + * @param mixed $data + * @param int $flags + * @param int $depth + * @return string + * + * @throws \JsonException + */ + protected function convertDataToJavaScriptExpression($data, $flags = 0, $depth = 512) + { + if ($data instanceof self) { + return $data->toHtml(); + } + + $json = $this->jsonEncode($data, $flags, $depth); + + if (is_string($data)) { + return "'".substr($json, 1, -1)."'"; + } + + return $this->convertJsonToJavaScriptExpression($json, $flags); + } + + /** + * Encode the given data as JSON. + * + * @param mixed $data + * @param int $flags + * @param int $depth + * @return string + * + * @throws \JsonException + */ + protected function jsonEncode($data, $flags = 0, $depth = 512) + { + if ($data instanceof Jsonable) { + return $data->toJson($flags | static::REQUIRED_FLAGS); + } + + if ($data instanceof Arrayable && ! ($data instanceof JsonSerializable)) { + $data = $data->toArray(); + } + + return json_encode($data, $flags | static::REQUIRED_FLAGS, $depth); + } + + /** + * Convert the given JSON to a JavaScript expression. + * + * @param string $json + * @param int $flags + * @return string + * + * @throws \JsonException + */ + protected function convertJsonToJavaScriptExpression($json, $flags = 0) + { + if ('[]' === $json || '{}' === $json) { + return $json; + } + + if (Str::startsWith($json, ['"', '{', '['])) { + return "JSON.parse('".substr(json_encode($json, $flags | static::REQUIRED_FLAGS), 1, -1)."')"; + } + + return $json; + } + + /** + * Get the string representation of the data for use in HTML. + * + * @return string + */ + public function toHtml() + { + return $this->js; + } + + /** + * Get the string representation of the data for use in HTML. + * + * @return string + */ + public function __toString() + { + return $this->toHtml(); + } +} diff --git a/src/Illuminate/Support/Manager.php b/src/Illuminate/Support/Manager.php index 917958ec3a54e3fc11ce0d602f4631b7967223cf..f8ae0729b16fa96a619d649d8dede2424cd0b09c 100755 --- a/src/Illuminate/Support/Manager.php +++ b/src/Illuminate/Support/Manager.php @@ -15,15 +15,6 @@ abstract class Manager */ protected $container; - /** - * The container instance. - * - * @var \Illuminate\Contracts\Container\Container - * - * @deprecated Use the $container property instead. - */ - protected $app; - /** * The configuration repository instance. * @@ -53,7 +44,6 @@ abstract class Manager */ public function __construct(Container $container) { - $this->app = $container; $this->container = $container; $this->config = $container->make('config'); } @@ -68,7 +58,7 @@ abstract class Manager /** * Get a driver instance. * - * @param string $driver + * @param string|null $driver * @return mixed * * @throws \InvalidArgumentException @@ -154,6 +144,41 @@ abstract class Manager return $this->drivers; } + /** + * Get the container instance used by the manager. + * + * @return \Illuminate\Contracts\Container\Container + */ + public function getContainer() + { + return $this->container; + } + + /** + * Set the container instance used by the manager. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } + + /** + * Forget all of the resolved driver instances. + * + * @return $this + */ + public function forgetDrivers() + { + $this->drivers = []; + + return $this; + } + /** * Dynamically call the default driver instance. * diff --git a/src/Illuminate/Support/MessageBag.php b/src/Illuminate/Support/MessageBag.php index 1fb862a594086d227613b7da9a60ce8ff874c2c0..e53d509d37cb5dd71d067b3f9bc1a1a43bda86f1 100755 --- a/src/Illuminate/Support/MessageBag.php +++ b/src/Illuminate/Support/MessageBag.php @@ -2,14 +2,13 @@ namespace Illuminate\Support; -use Countable; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Contracts\Support\MessageBag as MessageBagContract; use Illuminate\Contracts\Support\MessageProvider; use JsonSerializable; -class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, MessageBagContract, MessageProvider +class MessageBag implements Jsonable, JsonSerializable, MessageBagContract, MessageProvider { /** * All of the registered messages. @@ -66,6 +65,19 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me return $this; } + /** + * Add a message to the message bag if the given conditional is "true". + * + * @param bool $boolean + * @param string $key + * @param string $message + * @return $this + */ + public function addIf($boolean, $key, $message) + { + return $boolean ? $this->add($key, $message) : $this; + } + /** * Determine if a key and message combination already exists. * @@ -100,7 +112,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me /** * Determine if messages exist for all of the given keys. * - * @param array|string $key + * @param array|string|null $key * @return bool */ public function has($key) @@ -167,7 +179,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me * Get all of the messages from the message bag for a given key. * * @param string $key - * @param string $format + * @param string|null $format * @return array */ public function get($key, $format = null) @@ -211,7 +223,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me /** * Get all of the messages for every key in the message bag. * - * @param string $format + * @param string|null $format * @return array */ public function all($format = null) @@ -230,7 +242,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me /** * Get all of the unique messages for every key in the message bag. * - * @param string $format + * @param string|null $format * @return array */ public function unique($format = null) @@ -356,6 +368,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me * * @return int */ + #[\ReturnTypeWillChange] public function count() { return count($this->messages, COUNT_RECURSIVE) - count($this->messages); @@ -376,6 +389,7 @@ class MessageBag implements Arrayable, Countable, Jsonable, JsonSerializable, Me * * @return array */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); diff --git a/src/Illuminate/Support/MultipleInstanceManager.php b/src/Illuminate/Support/MultipleInstanceManager.php new file mode 100644 index 0000000000000000000000000000000000000000..97cee33af20146aa75ea9088c5223b1d1d2c3967 --- /dev/null +++ b/src/Illuminate/Support/MultipleInstanceManager.php @@ -0,0 +1,191 @@ +<?php + +namespace Illuminate\Support; + +use Closure; +use InvalidArgumentException; +use RuntimeException; + +abstract class MultipleInstanceManager +{ + /** + * The application instance. + * + * @var \Illuminate\Contracts\Foundation\Application + */ + protected $app; + + /** + * The array of resolved instances. + * + * @var array + */ + protected $instances = []; + + /** + * The registered custom instance creators. + * + * @var array + */ + protected $customCreators = []; + + /** + * Create a new manager instance. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return void + */ + public function __construct($app) + { + $this->app = $app; + } + + /** + * Get the default instance name. + * + * @return string + */ + abstract public function getDefaultInstance(); + + /** + * Set the default instance name. + * + * @param string $name + * @return void + */ + abstract public function setDefaultInstance($name); + + /** + * Get the instance specific configuration. + * + * @param string $name + * @return array + */ + abstract public function getInstanceConfig($name); + + /** + * Get an instance instance by name. + * + * @param string|null $name + * @return mixed + */ + public function instance($name = null) + { + $name = $name ?: $this->getDefaultInstance(); + + return $this->instances[$name] = $this->get($name); + } + + /** + * Attempt to get an instance from the local cache. + * + * @param string $name + * @return mixed + */ + protected function get($name) + { + return $this->instances[$name] ?? $this->resolve($name); + } + + /** + * Resolve the given instance. + * + * @param string $name + * @return mixed + * + * @throws \InvalidArgumentException + */ + protected function resolve($name) + { + $config = $this->getInstanceConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Instance [{$name}] is not defined."); + } + + if (! array_key_exists('driver', $config)) { + throw new RuntimeException("Instance [{$name}] does not specify a driver."); + } + + if (isset($this->customCreators[$config['driver']])) { + return $this->callCustomCreator($config); + } else { + $driverMethod = 'create'.ucfirst($config['driver']).'Driver'; + + if (method_exists($this, $driverMethod)) { + return $this->{$driverMethod}($config); + } else { + throw new InvalidArgumentException("Instance driver [{$config['driver']}] is not supported."); + } + } + } + + /** + * Call a custom instance creator. + * + * @param array $config + * @return mixed + */ + protected function callCustomCreator(array $config) + { + return $this->customCreators[$config['driver']]($this->app, $config); + } + + /** + * Unset the given instances. + * + * @param array|string|null $name + * @return $this + */ + public function forgetInstance($name = null) + { + $name = $name ?? $this->getDefaultInstance(); + + foreach ((array) $name as $instanceName) { + if (isset($this->instances[$instanceName])) { + unset($this->instances[$instanceName]); + } + } + + return $this; + } + + /** + * Disconnect the given instance and remove from local cache. + * + * @param string|null $name + * @return void + */ + public function purge($name = null) + { + $name = $name ?? $this->getDefaultInstance(); + + unset($this->instances[$name]); + } + + /** + * Register a custom instance creator Closure. + * + * @param string $name + * @param \Closure $callback + * @return $this + */ + public function extend($name, Closure $callback) + { + $this->customCreators[$name] = $callback->bindTo($this, $this); + + return $this; + } + + /** + * Dynamically call the default instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->instance()->$method(...$parameters); + } +} diff --git a/src/Illuminate/Support/NamespacedItemResolver.php b/src/Illuminate/Support/NamespacedItemResolver.php index e9251db609765ec73581e3cef12ba167732bf74b..a0d8508b281e2dff6fddd2f928da0cc0783cd695 100755 --- a/src/Illuminate/Support/NamespacedItemResolver.php +++ b/src/Illuminate/Support/NamespacedItemResolver.php @@ -99,4 +99,14 @@ class NamespacedItemResolver { $this->parsed[$key] = $parsed; } + + /** + * Flush the cache of parsed keys. + * + * @return void + */ + public function flushParsedKeys() + { + $this->parsed = []; + } } diff --git a/src/Illuminate/Support/Optional.php b/src/Illuminate/Support/Optional.php index 5e0b7d9cb040f753899704481aea06e4283cc11d..816190dd7a2e91ca7d55e5df422cc04d96e8603c 100644 --- a/src/Illuminate/Support/Optional.php +++ b/src/Illuminate/Support/Optional.php @@ -4,10 +4,11 @@ namespace Illuminate\Support; use ArrayAccess; use ArrayObject; +use Illuminate\Support\Traits\Macroable; class Optional implements ArrayAccess { - use Traits\Macroable { + use Macroable { __call as macroCall; } @@ -67,6 +68,7 @@ class Optional implements ArrayAccess * @param mixed $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return Arr::accessible($this->value) && Arr::exists($this->value, $key); @@ -78,6 +80,7 @@ class Optional implements ArrayAccess * @param mixed $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return Arr::get($this->value, $key); @@ -90,6 +93,7 @@ class Optional implements ArrayAccess * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { if (Arr::accessible($this->value)) { @@ -103,6 +107,7 @@ class Optional implements ArrayAccess * @param string $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { if (Arr::accessible($this->value)) { diff --git a/src/Illuminate/Support/Pluralizer.php b/src/Illuminate/Support/Pluralizer.php index 03719d4e22d8f60f59d1f3233a81647e7ed25dab..109a10816d7f16d6d64158833c3b4212f8d07550 100755 --- a/src/Illuminate/Support/Pluralizer.php +++ b/src/Illuminate/Support/Pluralizer.php @@ -2,17 +2,15 @@ namespace Illuminate\Support; -use Doctrine\Inflector\CachedWordInflector; use Doctrine\Inflector\Inflector; -use Doctrine\Inflector\Rules\English; -use Doctrine\Inflector\RulesetInflector; +use Doctrine\Inflector\InflectorFactory; class Pluralizer { /** * Uncountable word forms. * - * @var array + * @var string[] */ public static $uncountable = [ 'audio', @@ -64,12 +62,16 @@ class Pluralizer * Get the plural form of an English word. * * @param string $value - * @param int $count + * @param int|array|\Countable $count * @return string */ public static function plural($value, $count = 2) { - if ((int) abs($count) === 1 || static::uncountable($value)) { + if (is_countable($count)) { + $count = count($count); + } + + if ((int) abs($count) === 1 || static::uncountable($value) || preg_match('/^(.*)[A-Za-z0-9\x{0080}-\x{FFFF}]$/u', $value) == 0) { return $value; } @@ -132,14 +134,7 @@ class Pluralizer static $inflector; if (is_null($inflector)) { - $inflector = new Inflector( - new CachedWordInflector(new RulesetInflector( - English\Rules::getSingularRuleset() - )), - new CachedWordInflector(new RulesetInflector( - English\Rules::getPluralRuleset() - )) - ); + $inflector = InflectorFactory::createForLanguage('english')->build(); } return $inflector; diff --git a/src/Illuminate/Support/Reflector.php b/src/Illuminate/Support/Reflector.php index 66392ca2f39a618be0f517ae3c438b823e8ac625..841bad68629db8f3bea2da00b1156cf5a005894b 100644 --- a/src/Illuminate/Support/Reflector.php +++ b/src/Illuminate/Support/Reflector.php @@ -5,6 +5,7 @@ namespace Illuminate\Support; use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; +use ReflectionUnionType; class Reflector { @@ -69,6 +70,45 @@ class Reflector return; } + return static::getTypeName($parameter, $type); + } + + /** + * Get the class names of the given parameter's type, including union types. + * + * @param \ReflectionParameter $parameter + * @return array + */ + public static function getParameterClassNames($parameter) + { + $type = $parameter->getType(); + + if (! $type instanceof ReflectionUnionType) { + return array_filter([static::getParameterClassName($parameter)]); + } + + $unionTypes = []; + + foreach ($type->getTypes() as $listedType) { + if (! $listedType instanceof ReflectionNamedType || $listedType->isBuiltin()) { + continue; + } + + $unionTypes[] = static::getTypeName($parameter, $listedType); + } + + return array_filter($unionTypes); + } + + /** + * Get the given type's class name. + * + * @param \ReflectionParameter $parameter + * @param \ReflectionNamedType $type + * @return string + */ + protected static function getTypeName($parameter, $type) + { $name = $type->getName(); if (! is_null($class = $parameter->getDeclaringClass())) { @@ -95,8 +135,8 @@ class Reflector { $paramClassName = static::getParameterClassName($parameter); - return ($paramClassName && class_exists($paramClassName)) - ? (new ReflectionClass($paramClassName))->isSubclassOf($className) - : false; + return $paramClassName + && (class_exists($paramClassName) || interface_exists($paramClassName)) + && (new ReflectionClass($paramClassName))->isSubclassOf($className); } } diff --git a/src/Illuminate/Support/ServiceProvider.php b/src/Illuminate/Support/ServiceProvider.php index 983940bdc875530c56ba91611b3193b2b9d8531a..6c530c121d3c3d3dd8dab7250d1346b0609faeee 100755 --- a/src/Illuminate/Support/ServiceProvider.php +++ b/src/Illuminate/Support/ServiceProvider.php @@ -2,9 +2,13 @@ namespace Illuminate\Support; +use Closure; use Illuminate\Console\Application as Artisan; +use Illuminate\Contracts\Foundation\CachesConfiguration; +use Illuminate\Contracts\Foundation\CachesRoutes; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Database\Eloquent\Factory as ModelFactory; +use Illuminate\View\Compilers\BladeCompiler; abstract class ServiceProvider { @@ -15,6 +19,20 @@ abstract class ServiceProvider */ protected $app; + /** + * All of the registered booting callbacks. + * + * @var array + */ + protected $bootingCallbacks = []; + + /** + * All of the registered booted callbacks. + * + * @var array + */ + protected $bootedCallbacks = []; + /** * The paths that should be published. * @@ -50,6 +68,60 @@ abstract class ServiceProvider // } + /** + * Register a booting callback to be run before the "boot" method is called. + * + * @param \Closure $callback + * @return void + */ + public function booting(Closure $callback) + { + $this->bootingCallbacks[] = $callback; + } + + /** + * Register a booted callback to be run after the "boot" method is called. + * + * @param \Closure $callback + * @return void + */ + public function booted(Closure $callback) + { + $this->bootedCallbacks[] = $callback; + } + + /** + * Call the registered booting callbacks. + * + * @return void + */ + public function callBootingCallbacks() + { + $index = 0; + + while ($index < count($this->bootingCallbacks)) { + $this->app->call($this->bootingCallbacks[$index]); + + $index++; + } + } + + /** + * Call the registered booted callbacks. + * + * @return void + */ + public function callBootedCallbacks() + { + $index = 0; + + while ($index < count($this->bootedCallbacks)) { + $this->app->call($this->bootedCallbacks[$index]); + + $index++; + } + } + /** * Merge the given configuration with the existing configuration. * @@ -59,9 +131,11 @@ abstract class ServiceProvider */ protected function mergeConfigFrom($path, $key) { - if (! $this->app->configurationIsCached()) { - $this->app['config']->set($key, array_merge( - require $path, $this->app['config']->get($key, []) + if (! ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached())) { + $config = $this->app->make('config'); + + $config->set($key, array_merge( + require $path, $config->get($key, []) )); } } @@ -74,7 +148,7 @@ abstract class ServiceProvider */ protected function loadRoutesFrom($path) { - if (! $this->app->routesAreCached()) { + if (! ($this->app instanceof CachesRoutes && $this->app->routesAreCached())) { require $path; } } @@ -102,6 +176,22 @@ abstract class ServiceProvider }); } + /** + * Register the given view components with a custom prefix. + * + * @param string $prefix + * @param array $components + * @return void + */ + protected function loadViewComponentsAs($prefix, array $components) + { + $this->callAfterResolving(BladeCompiler::class, function ($blade) use ($prefix, $components) { + foreach ($components as $alias => $component) { + $blade->component($component, is_string($alias) ? $alias : null, $prefix); + } + }); + } + /** * Register a translation file namespace. * @@ -147,6 +237,8 @@ abstract class ServiceProvider /** * Register Eloquent model factory paths. * + * @deprecated Will be removed in a future Laravel version. + * * @param array|string $paths * @return void */ diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index 3497e851caf03ea3f0e09672704c4634404abc84..21e19040389c6936622d715e46cafb6f9817e95f 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -3,10 +3,12 @@ namespace Illuminate\Support; use Illuminate\Support\Traits\Macroable; +use League\CommonMark\GithubFlavoredMarkdownConverter; use Ramsey\Uuid\Codec\TimestampFirstCombCodec; use Ramsey\Uuid\Generator\CombGenerator; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidFactory; +use voku\helper\ASCII; class Str { @@ -40,6 +42,17 @@ class Str */ protected static $uuidFactory; + /** + * Get a new stringable object from the given string. + * + * @param string $string + * @return \Illuminate\Support\Stringable + */ + public static function of($string) + { + return new Stringable($string); + } + /** * Return the remainder of a string after the first occurrence of a given value. * @@ -83,17 +96,20 @@ class Str */ public static function ascii($value, $language = 'en') { - $languageSpecific = static::languageSpecificCharsArray($language); - - if (! is_null($languageSpecific)) { - $value = str_replace($languageSpecific[0], $languageSpecific[1], $value); - } - - foreach (static::charsArray() as $key => $val) { - $value = str_replace($val, $key, $value); - } + return ASCII::to_ascii((string) $value, $language); + } - return preg_replace('/[^\x20-\x7E]/u', '', $value); + /** + * Transliterate a string to its closest ASCII representation. + * + * @param string $string + * @param string|null $unknown + * @param bool|null $strict + * @return string + */ + public static function transliterate($string, $unknown = '?', $strict = false) + { + return ASCII::to_transliterate($string, $unknown, $strict); } /** @@ -105,7 +121,13 @@ class Str */ public static function before($subject, $search) { - return $search === '' ? $subject : explode($search, $subject)[0]; + if ($search === '') { + return $subject; + } + + $result = strstr($subject, (string) $search, true); + + return $result === false ? $subject : $result; } /** @@ -130,6 +152,23 @@ class Str return static::substr($subject, 0, $pos); } + /** + * Get the portion of a string between two given values. + * + * @param string $subject + * @param string $from + * @param string $to + * @return string + */ + public static function between($subject, $from, $to) + { + if ($from === '' || $to === '') { + return $subject; + } + + return static::beforeLast(static::after($subject, $from), $to); + } + /** * Convert a value to camel case. * @@ -191,7 +230,10 @@ class Str public static function endsWith($haystack, $needles) { foreach ((array) $needles as $needle) { - if (substr($haystack, -strlen($needle)) === (string) $needle) { + if ( + $needle !== '' && $needle !== null + && substr($haystack, -strlen($needle)) === (string) $needle + ) { return true; } } @@ -224,11 +266,15 @@ class Str { $patterns = Arr::wrap($pattern); + $value = (string) $value; + if (empty($patterns)) { return false; } foreach ($patterns as $pattern) { + $pattern = (string) $pattern; + // If the given value is an exact match we can of course return true right // from the beginning. Otherwise, we will translate asterisks and do an // actual pattern match against the two strings to see if they match. @@ -251,6 +297,17 @@ class Str return false; } + /** + * Determine if a given string is 7 bit ASCII. + * + * @param string $value + * @return bool + */ + public static function isAscii($value) + { + return ASCII::is_ascii((string) $value); + } + /** * Determine if a given string is a valid UUID. * @@ -340,6 +397,135 @@ class Str return rtrim($matches[0]).$end; } + /** + * Converts GitHub flavored Markdown into HTML. + * + * @param string $string + * @param array $options + * @return string + */ + public static function markdown($string, array $options = []) + { + $converter = new GithubFlavoredMarkdownConverter($options); + + return (string) $converter->convertToHtml($string); + } + + /** + * Masks a portion of a string with a repeated character. + * + * @param string $string + * @param string $character + * @param int $index + * @param int|null $length + * @param string $encoding + * @return string + */ + public static function mask($string, $character, $index, $length = null, $encoding = 'UTF-8') + { + if ($character === '') { + return $string; + } + + if (is_null($length) && PHP_MAJOR_VERSION < 8) { + $length = mb_strlen($string, $encoding); + } + + $segment = mb_substr($string, $index, $length, $encoding); + + if ($segment === '') { + return $string; + } + + $strlen = mb_strlen($string, $encoding); + $startIndex = $index; + + if ($index < 0) { + $startIndex = $index < -$strlen ? 0 : $strlen + $index; + } + + $start = mb_substr($string, 0, $startIndex, $encoding); + $segmentLen = mb_strlen($segment, $encoding); + $end = mb_substr($string, $startIndex + $segmentLen); + + return $start.str_repeat(mb_substr($character, 0, 1, $encoding), $segmentLen).$end; + } + + /** + * Get the string matching the given pattern. + * + * @param string $pattern + * @param string $subject + * @return string + */ + public static function match($pattern, $subject) + { + preg_match($pattern, $subject, $matches); + + if (! $matches) { + return ''; + } + + return $matches[1] ?? $matches[0]; + } + + /** + * Get the string matching the given pattern. + * + * @param string $pattern + * @param string $subject + * @return \Illuminate\Support\Collection + */ + public static function matchAll($pattern, $subject) + { + preg_match_all($pattern, $subject, $matches); + + if (empty($matches[0])) { + return collect(); + } + + return collect($matches[1] ?? $matches[0]); + } + + /** + * Pad both sides of a string with another. + * + * @param string $value + * @param int $length + * @param string $pad + * @return string + */ + public static function padBoth($value, $length, $pad = ' ') + { + return str_pad($value, strlen($value) - mb_strlen($value) + $length, $pad, STR_PAD_BOTH); + } + + /** + * Pad the left side of a string with another. + * + * @param string $value + * @param int $length + * @param string $pad + * @return string + */ + public static function padLeft($value, $length, $pad = ' ') + { + return str_pad($value, strlen($value) - mb_strlen($value) + $length, $pad, STR_PAD_LEFT); + } + + /** + * Pad the right side of a string with another. + * + * @param string $value + * @param int $length + * @param string $pad + * @return string + */ + public static function padRight($value, $length, $pad = ' ') + { + return str_pad($value, strlen($value) - mb_strlen($value) + $length, $pad, STR_PAD_RIGHT); + } + /** * Parse a Class[@]method style callback into class and method. * @@ -356,7 +542,7 @@ class Str * Get the plural form of an English word. * * @param string $value - * @param int $count + * @param int|array|\Countable $count * @return string */ public static function plural($value, $count = 2) @@ -368,7 +554,7 @@ class Str * Pluralize the last word of an English, studly caps case string. * * @param string $value - * @param int $count + * @param int|array|\Countable $count * @return string */ public static function pluralStudly($value, $count = 2) @@ -401,6 +587,18 @@ class Str return $string; } + /** + * Repeat the given string. + * + * @param string $string + * @param int $times + * @return string + */ + public static function repeat(string $string, int $times) + { + return str_repeat($string, $times); + } + /** * Replace a given value in the string sequentially with an array. * @@ -422,6 +620,19 @@ class Str return $result; } + /** + * Replace the given value in the given string. + * + * @param string|string[] $search + * @param string|string[] $replace + * @param string|string[] $subject + * @return string + */ + public static function replace($search, $replace, $subject) + { + return str_replace($search, $replace, $subject); + } + /** * Replace the first occurrence of a given value in the string. * @@ -432,7 +643,7 @@ class Str */ public static function replaceFirst($search, $replace, $subject) { - if ($search == '') { + if ($search === '') { return $subject; } @@ -468,6 +679,34 @@ class Str return $subject; } + /** + * Remove any occurrence of the given string in the subject. + * + * @param string|array<string> $search + * @param string $subject + * @param bool $caseSensitive + * @return string + */ + public static function remove($search, $subject, $caseSensitive = true) + { + $subject = $caseSensitive + ? str_replace($search, '', $subject) + : str_ireplace($search, '', $subject); + + return $subject; + } + + /** + * Reverse the given string. + * + * @param string $value + * @return string + */ + public static function reverse(string $value) + { + return implode(array_reverse(mb_str_split($value))); + } + /** * Begin a string with a single instance of a given value. * @@ -504,6 +743,25 @@ class Str return mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); } + /** + * Convert the given string to title case for each word. + * + * @param string $value + * @return string + */ + public static function headline($value) + { + $parts = explode(' ', $value); + + $parts = count($parts) > 1 + ? $parts = array_map([static::class, 'title'], $parts) + : $parts = array_map([static::class, 'title'], static::ucsplit(implode('_', $parts))); + + $collapsed = static::replace(['-', '_', ' '], '_', implode('_', $parts)); + + return implode(' ', array_filter(explode('_', $collapsed))); + } + /** * Get the singular form of an English word. * @@ -578,7 +836,7 @@ class Str public static function startsWith($haystack, $needles) { foreach ((array) $needles as $needle) { - if ($needle !== '' && substr($haystack, 0, strlen($needle)) === (string) $needle) { + if ((string) $needle !== '' && strncmp($haystack, $needle, strlen($needle)) === 0) { return true; } } @@ -600,13 +858,17 @@ class Str return static::$studlyCache[$key]; } - $value = ucwords(str_replace(['-', '_'], ' ', $value)); + $words = explode(' ', static::replace(['-', '_'], ' ', $value)); - return static::$studlyCache[$key] = str_replace(' ', '', $value); + $studlyWords = array_map(function ($word) { + return static::ucfirst($word); + }, $words); + + return static::$studlyCache[$key] = implode($studlyWords); } /** - * Returns the portion of string specified by the start and length parameters. + * Returns the portion of the string specified by the start and length parameters. * * @param string $string * @param int $start @@ -618,6 +880,54 @@ class Str return mb_substr($string, $start, $length, 'UTF-8'); } + /** + * Returns the number of substring occurrences. + * + * @param string $haystack + * @param string $needle + * @param int $offset + * @param int|null $length + * @return int + */ + public static function substrCount($haystack, $needle, $offset = 0, $length = null) + { + if (! is_null($length)) { + return substr_count($haystack, $needle, $offset, $length); + } else { + return substr_count($haystack, $needle, $offset); + } + } + + /** + * Replace text within a portion of a string. + * + * @param string|array $string + * @param string|array $replace + * @param array|int $offset + * @param array|int|null $length + * @return string|array + */ + public static function substrReplace($string, $replace, $offset = 0, $length = null) + { + if ($length === null) { + $length = strlen($string); + } + + return substr_replace($string, $replace, $offset, $length); + } + + /** + * Swap multiple keywords in a string with other keywords. + * + * @param array $map + * @param string $subject + * @return string + */ + public static function swap(array $map, $subject) + { + return strtr($subject, $map); + } + /** * Make a string's first character uppercase. * @@ -629,6 +939,28 @@ class Str return static::upper(static::substr($string, 0, 1)).static::substr($string, 1); } + /** + * Split a string into pieces by uppercase characters. + * + * @param string $string + * @return array + */ + public static function ucsplit($string) + { + return preg_split('/(?=\p{Lu})/u', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * Get the number of words a string contains. + * + * @param string $string + * @return int + */ + public static function wordCount($string) + { + return str_word_count($string); + } + /** * Generate a UUID (version 4). * @@ -652,7 +984,7 @@ class Str return call_user_func(static::$uuidFactory); } - $factory = new UuidFactory(); + $factory = new UuidFactory; $factory->setRandomGenerator(new CombGenerator( $factory->getRandomGenerator(), @@ -669,7 +1001,7 @@ class Str /** * Set the callable that will be used to generate UUIDs. * - * @param callable $factory + * @param callable|null $factory * @return void */ public static function createUuidsUsing(callable $factory = null) @@ -688,180 +1020,14 @@ class Str } /** - * Returns the replacements for the ascii method. - * - * Note: Adapted from Stringy\Stringy. + * Remove all strings from the casing caches. * - * @see https://github.com/danielstjules/Stringy/blob/3.1.0/LICENSE.txt - * - * @return array + * @return void */ - protected static function charsArray() - { - static $charsArray; - - if (isset($charsArray)) { - return $charsArray; - } - - return $charsArray = [ - '0' => ['°', '₀', '۰', '0'], - '1' => ['¹', '₁', '۱', '1'], - '2' => ['²', '₂', '۲', '2'], - '3' => ['³', '₃', '۳', '3'], - '4' => ['⁴', '₄', '۴', '٤', '4'], - '5' => ['⁵', '₅', '۵', '٥', '5'], - '6' => ['⁶', '₆', '۶', '٦', '6'], - '7' => ['⁷', '₇', '۷', '7'], - '8' => ['⁸', '₈', '۸', '8'], - '9' => ['⁹', '₉', '۹', '9'], - 'a' => ['à', 'á', 'ả', 'ã', 'ạ', 'ă', 'ắ', 'ằ', 'ẳ', 'ẵ', 'ặ', 'â', 'ấ', 'ầ', 'ẩ', 'ẫ', 'ậ', 'ā', 'ą', 'å', 'α', 'ά', 'ἀ', 'ἁ', 'ἂ', 'ἃ', 'ἄ', 'ἅ', 'ἆ', 'ἇ', 'ᾀ', 'ᾁ', 'ᾂ', 'ᾃ', 'ᾄ', 'ᾅ', 'ᾆ', 'ᾇ', 'ὰ', 'ά', 'ᾰ', 'ᾱ', 'ᾲ', 'ᾳ', 'ᾴ', 'ᾶ', 'ᾷ', 'а', 'أ', 'အ', 'ာ', 'ါ', 'ǻ', 'ǎ', 'ª', 'ა', 'अ', 'ا', 'a', 'ä', 'א'], - 'b' => ['б', 'β', 'ب', 'ဗ', 'ბ', 'b', 'ב'], - 'c' => ['ç', 'ć', 'č', 'ĉ', 'ċ', 'c'], - 'd' => ['ď', 'ð', 'đ', 'ƌ', 'ȡ', 'ɖ', 'ɗ', 'ᵭ', 'ᶁ', 'ᶑ', 'д', 'δ', 'د', 'ض', 'ဍ', 'ဒ', 'დ', 'd', 'ד'], - 'e' => ['é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ế', 'ề', 'ể', 'ễ', 'ệ', 'ë', 'ē', 'ę', 'ě', 'ĕ', 'ė', 'ε', 'έ', 'ἐ', 'ἑ', 'ἒ', 'ἓ', 'ἔ', 'ἕ', 'ὲ', 'έ', 'е', 'ё', 'э', 'є', 'ə', 'ဧ', 'ေ', 'ဲ', 'ე', 'ए', 'إ', 'ئ', 'e'], - 'f' => ['ф', 'φ', 'ف', 'ƒ', 'ფ', 'f', 'פ', 'ף'], - 'g' => ['ĝ', 'ğ', 'ġ', 'ģ', 'г', 'ґ', 'γ', 'ဂ', 'გ', 'گ', 'g', 'ג'], - 'h' => ['ĥ', 'ħ', 'η', 'ή', 'ح', 'ه', 'ဟ', 'ှ', 'ჰ', 'h', 'ה'], - 'i' => ['í', 'ì', 'ỉ', 'ĩ', 'ị', 'î', 'ï', 'ī', 'ĭ', 'į', 'ı', 'ι', 'ί', 'ϊ', 'ΐ', 'ἰ', 'ἱ', 'ἲ', 'ἳ', 'ἴ', 'ἵ', 'ἶ', 'ἷ', 'ὶ', 'ί', 'ῐ', 'ῑ', 'ῒ', 'ΐ', 'ῖ', 'ῗ', 'і', 'ї', 'и', 'ဣ', 'ိ', 'ီ', 'ည်', 'ǐ', 'ი', 'इ', 'ی', 'i', 'י'], - 'j' => ['ĵ', 'ј', 'Ј', 'ჯ', 'ج', 'j'], - 'k' => ['ķ', 'ĸ', 'к', 'κ', 'Ķ', 'ق', 'ك', 'က', 'კ', 'ქ', 'ک', 'k', 'ק'], - 'l' => ['ł', 'ľ', 'ĺ', 'ļ', 'ŀ', 'л', 'λ', 'ل', 'လ', 'ლ', 'l', 'ל'], - 'm' => ['м', 'μ', 'م', 'မ', 'მ', 'm', 'מ', 'ם'], - 'n' => ['ñ', 'ń', 'ň', 'ņ', 'ʼn', 'ŋ', 'ν', 'н', 'ن', 'န', 'ნ', 'n', 'נ'], - 'o' => ['ó', 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ố', 'ồ', 'ổ', 'ỗ', 'ộ', 'ơ', 'ớ', 'ờ', 'ở', 'ỡ', 'ợ', 'ø', 'ō', 'ő', 'ŏ', 'ο', 'ὀ', 'ὁ', 'ὂ', 'ὃ', 'ὄ', 'ὅ', 'ὸ', 'ό', 'о', 'و', 'ို', 'ǒ', 'ǿ', 'º', 'ო', 'ओ', 'o', 'ö'], - 'p' => ['п', 'π', 'ပ', 'პ', 'پ', 'p', 'פ', 'ף'], - 'q' => ['ყ', 'q'], - 'r' => ['ŕ', 'ř', 'ŗ', 'р', 'ρ', 'ر', 'რ', 'r', 'ר'], - 's' => ['ś', 'š', 'ş', 'с', 'σ', 'ș', 'ς', 'س', 'ص', 'စ', 'ſ', 'ს', 's', 'ס'], - 't' => ['ť', 'ţ', 'т', 'τ', 'ț', 'ت', 'ط', 'ဋ', 'တ', 'ŧ', 'თ', 'ტ', 't', 'ת'], - 'u' => ['ú', 'ù', 'ủ', 'ũ', 'ụ', 'ư', 'ứ', 'ừ', 'ử', 'ữ', 'ự', 'û', 'ū', 'ů', 'ű', 'ŭ', 'ų', 'µ', 'у', 'ဉ', 'ု', 'ူ', 'ǔ', 'ǖ', 'ǘ', 'ǚ', 'ǜ', 'უ', 'उ', 'u', 'ў', 'ü'], - 'v' => ['в', 'ვ', 'ϐ', 'v', 'ו'], - 'w' => ['ŵ', 'ω', 'ώ', 'ဝ', 'ွ', 'w'], - 'x' => ['χ', 'ξ', 'x'], - 'y' => ['ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', 'ÿ', 'ŷ', 'й', 'ы', 'υ', 'ϋ', 'ύ', 'ΰ', 'ي', 'ယ', 'y'], - 'z' => ['ź', 'ž', 'ż', 'з', 'ζ', 'ز', 'ဇ', 'ზ', 'z', 'ז'], - 'aa' => ['ع', 'आ', 'آ'], - 'ae' => ['æ', 'ǽ'], - 'ai' => ['ऐ'], - 'ch' => ['ч', 'ჩ', 'ჭ', 'چ'], - 'dj' => ['ђ', 'đ'], - 'dz' => ['џ', 'ძ', 'דז'], - 'ei' => ['ऍ'], - 'gh' => ['غ', 'ღ'], - 'ii' => ['ई'], - 'ij' => ['ij'], - 'kh' => ['х', 'خ', 'ხ'], - 'lj' => ['љ'], - 'nj' => ['њ'], - 'oe' => ['ö', 'œ', 'ؤ'], - 'oi' => ['ऑ'], - 'oii' => ['ऒ'], - 'ps' => ['ψ'], - 'sh' => ['ш', 'შ', 'ش', 'ש'], - 'shch' => ['щ'], - 'ss' => ['ß'], - 'sx' => ['ŝ'], - 'th' => ['þ', 'ϑ', 'θ', 'ث', 'ذ', 'ظ'], - 'ts' => ['ц', 'ც', 'წ'], - 'ue' => ['ü'], - 'uu' => ['ऊ'], - 'ya' => ['я'], - 'yu' => ['ю'], - 'zh' => ['ж', 'ჟ', 'ژ'], - '(c)' => ['©'], - 'A' => ['Á', 'À', 'Ả', 'Ã', 'Ạ', 'Ă', 'Ắ', 'Ằ', 'Ẳ', 'Ẵ', 'Ặ', 'Â', 'Ấ', 'Ầ', 'Ẩ', 'Ẫ', 'Ậ', 'Å', 'Ā', 'Ą', 'Α', 'Ά', 'Ἀ', 'Ἁ', 'Ἂ', 'Ἃ', 'Ἄ', 'Ἅ', 'Ἆ', 'Ἇ', 'ᾈ', 'ᾉ', 'ᾊ', 'ᾋ', 'ᾌ', 'ᾍ', 'ᾎ', 'ᾏ', 'Ᾰ', 'Ᾱ', 'Ὰ', 'Ά', 'ᾼ', 'А', 'Ǻ', 'Ǎ', 'A', 'Ä'], - 'B' => ['Б', 'Β', 'ब', 'B'], - 'C' => ['Ç', 'Ć', 'Č', 'Ĉ', 'Ċ', 'C'], - 'D' => ['Ď', 'Ð', 'Đ', 'Ɖ', 'Ɗ', 'Ƌ', 'ᴅ', 'ᴆ', 'Д', 'Δ', 'D'], - 'E' => ['É', 'È', 'Ẻ', 'Ẽ', 'Ẹ', 'Ê', 'Ế', 'Ề', 'Ể', 'Ễ', 'Ệ', 'Ë', 'Ē', 'Ę', 'Ě', 'Ĕ', 'Ė', 'Ε', 'Έ', 'Ἐ', 'Ἑ', 'Ἒ', 'Ἓ', 'Ἔ', 'Ἕ', 'Έ', 'Ὲ', 'Е', 'Ё', 'Э', 'Є', 'Ə', 'E'], - 'F' => ['Ф', 'Φ', 'F'], - 'G' => ['Ğ', 'Ġ', 'Ģ', 'Г', 'Ґ', 'Γ', 'G'], - 'H' => ['Η', 'Ή', 'Ħ', 'H'], - 'I' => ['Í', 'Ì', 'Ỉ', 'Ĩ', 'Ị', 'Î', 'Ï', 'Ī', 'Ĭ', 'Į', 'İ', 'Ι', 'Ί', 'Ϊ', 'Ἰ', 'Ἱ', 'Ἳ', 'Ἴ', 'Ἵ', 'Ἶ', 'Ἷ', 'Ῐ', 'Ῑ', 'Ὶ', 'Ί', 'И', 'І', 'Ї', 'Ǐ', 'ϒ', 'I'], - 'J' => ['J'], - 'K' => ['К', 'Κ', 'K'], - 'L' => ['Ĺ', 'Ł', 'Л', 'Λ', 'Ļ', 'Ľ', 'Ŀ', 'ल', 'L'], - 'M' => ['М', 'Μ', 'M'], - 'N' => ['Ń', 'Ñ', 'Ň', 'Ņ', 'Ŋ', 'Н', 'Ν', 'N'], - 'O' => ['Ó', 'Ò', 'Ỏ', 'Õ', 'Ọ', 'Ô', 'Ố', 'Ồ', 'Ổ', 'Ỗ', 'Ộ', 'Ơ', 'Ớ', 'Ờ', 'Ở', 'Ỡ', 'Ợ', 'Ø', 'Ō', 'Ő', 'Ŏ', 'Ο', 'Ό', 'Ὀ', 'Ὁ', 'Ὂ', 'Ὃ', 'Ὄ', 'Ὅ', 'Ὸ', 'Ό', 'О', 'Ө', 'Ǒ', 'Ǿ', 'O', 'Ö'], - 'P' => ['П', 'Π', 'P'], - 'Q' => ['Q'], - 'R' => ['Ř', 'Ŕ', 'Р', 'Ρ', 'Ŗ', 'R'], - 'S' => ['Ş', 'Ŝ', 'Ș', 'Š', 'Ś', 'С', 'Σ', 'S'], - 'T' => ['Ť', 'Ţ', 'Ŧ', 'Ț', 'Т', 'Τ', 'T'], - 'U' => ['Ú', 'Ù', 'Ủ', 'Ũ', 'Ụ', 'Ư', 'Ứ', 'Ừ', 'Ử', 'Ữ', 'Ự', 'Û', 'Ū', 'Ů', 'Ű', 'Ŭ', 'Ų', 'У', 'Ǔ', 'Ǖ', 'Ǘ', 'Ǚ', 'Ǜ', 'U', 'Ў', 'Ü'], - 'V' => ['В', 'V'], - 'W' => ['Ω', 'Ώ', 'Ŵ', 'W'], - 'X' => ['Χ', 'Ξ', 'X'], - 'Y' => ['Ý', 'Ỳ', 'Ỷ', 'Ỹ', 'Ỵ', 'Ÿ', 'Ῠ', 'Ῡ', 'Ὺ', 'Ύ', 'Ы', 'Й', 'Υ', 'Ϋ', 'Ŷ', 'Y'], - 'Z' => ['Ź', 'Ž', 'Ż', 'З', 'Ζ', 'Z'], - 'AE' => ['Æ', 'Ǽ'], - 'Ch' => ['Ч'], - 'Dj' => ['Ђ'], - 'Dz' => ['Џ'], - 'Gx' => ['Ĝ'], - 'Hx' => ['Ĥ'], - 'Ij' => ['IJ'], - 'Jx' => ['Ĵ'], - 'Kh' => ['Х'], - 'Lj' => ['Љ'], - 'Nj' => ['Њ'], - 'Oe' => ['Œ'], - 'Ps' => ['Ψ'], - 'Sh' => ['Ш', 'ש'], - 'Shch' => ['Щ'], - 'Ss' => ['ẞ'], - 'Th' => ['Þ', 'Θ', 'ת'], - 'Ts' => ['Ц'], - 'Ya' => ['Я', 'יא'], - 'Yu' => ['Ю', 'יו'], - 'Zh' => ['Ж'], - ' ' => ["\xC2\xA0", "\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", "\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", "\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", "\xE2\x80\xAF", "\xE2\x81\x9F", "\xE3\x80\x80", "\xEF\xBE\xA0"], - ]; - } - - /** - * Returns the language specific replacements for the ascii method. - * - * Note: Adapted from Stringy\Stringy. - * - * @see https://github.com/danielstjules/Stringy/blob/3.1.0/LICENSE.txt - * - * @param string $language - * @return array|null - */ - protected static function languageSpecificCharsArray($language) - { - static $languageSpecific; - - if (! isset($languageSpecific)) { - $languageSpecific = [ - 'bg' => [ - ['х', 'Х', 'щ', 'Щ', 'ъ', 'Ъ', 'ь', 'Ь'], - ['h', 'H', 'sht', 'SHT', 'a', 'А', 'y', 'Y'], - ], - 'da' => [ - ['æ', 'ø', 'å', 'Æ', 'Ø', 'Å'], - ['ae', 'oe', 'aa', 'Ae', 'Oe', 'Aa'], - ], - 'de' => [ - ['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü'], - ['ae', 'oe', 'ue', 'AE', 'OE', 'UE'], - ], - 'he' => [ - ['א', 'ב', 'ג', 'ד', 'ה', 'ו'], - ['ז', 'ח', 'ט', 'י', 'כ', 'ל'], - ['מ', 'נ', 'ס', 'ע', 'פ', 'צ'], - ['ק', 'ר', 'ש', 'ת', 'ן', 'ץ', 'ך', 'ם', 'ף'], - ], - 'ro' => [ - ['ă', 'â', 'î', 'ș', 'ț', 'Ă', 'Â', 'Î', 'Ș', 'Ț'], - ['a', 'a', 'i', 's', 't', 'A', 'A', 'I', 'S', 'T'], - ], - ]; - } - - return $languageSpecific[$language] ?? null; + public static function flushCache() + { + static::$snakeCache = []; + static::$camelCache = []; + static::$studlyCache = []; } } diff --git a/src/Illuminate/Support/Stringable.php b/src/Illuminate/Support/Stringable.php new file mode 100644 index 0000000000000000000000000000000000000000..414be0c2735492a18cc25a1b27c7986f4459645b --- /dev/null +++ b/src/Illuminate/Support/Stringable.php @@ -0,0 +1,1026 @@ +<?php + +namespace Illuminate\Support; + +use Closure; +use Illuminate\Support\Traits\Conditionable; +use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\Tappable; +use JsonSerializable; +use Symfony\Component\VarDumper\VarDumper; + +class Stringable implements JsonSerializable +{ + use Conditionable, Macroable, Tappable; + + /** + * The underlying string value. + * + * @var string + */ + protected $value; + + /** + * Create a new instance of the class. + * + * @param string $value + * @return void + */ + public function __construct($value = '') + { + $this->value = (string) $value; + } + + /** + * Return the remainder of a string after the first occurrence of a given value. + * + * @param string $search + * @return static + */ + public function after($search) + { + return new static(Str::after($this->value, $search)); + } + + /** + * Return the remainder of a string after the last occurrence of a given value. + * + * @param string $search + * @return static + */ + public function afterLast($search) + { + return new static(Str::afterLast($this->value, $search)); + } + + /** + * Append the given values to the string. + * + * @param array $values + * @return static + */ + public function append(...$values) + { + return new static($this->value.implode('', $values)); + } + + /** + * Transliterate a UTF-8 value to ASCII. + * + * @param string $language + * @return static + */ + public function ascii($language = 'en') + { + return new static(Str::ascii($this->value, $language)); + } + + /** + * Get the trailing name component of the path. + * + * @param string $suffix + * @return static + */ + public function basename($suffix = '') + { + return new static(basename($this->value, $suffix)); + } + + /** + * Get the basename of the class path. + * + * @return static + */ + public function classBasename() + { + return new static(class_basename($this->value)); + } + + /** + * Get the portion of a string before the first occurrence of a given value. + * + * @param string $search + * @return static + */ + public function before($search) + { + return new static(Str::before($this->value, $search)); + } + + /** + * Get the portion of a string before the last occurrence of a given value. + * + * @param string $search + * @return static + */ + public function beforeLast($search) + { + return new static(Str::beforeLast($this->value, $search)); + } + + /** + * Get the portion of a string between two given values. + * + * @param string $from + * @param string $to + * @return static + */ + public function between($from, $to) + { + return new static(Str::between($this->value, $from, $to)); + } + + /** + * Convert a value to camel case. + * + * @return static + */ + public function camel() + { + return new static(Str::camel($this->value)); + } + + /** + * Determine if a given string contains a given substring. + * + * @param string|array $needles + * @return bool + */ + public function contains($needles) + { + return Str::contains($this->value, $needles); + } + + /** + * Determine if a given string contains all array values. + * + * @param array $needles + * @return bool + */ + public function containsAll(array $needles) + { + return Str::containsAll($this->value, $needles); + } + + /** + * Get the parent directory's path. + * + * @param int $levels + * @return static + */ + public function dirname($levels = 1) + { + return new static(dirname($this->value, $levels)); + } + + /** + * Determine if a given string ends with a given substring. + * + * @param string|array $needles + * @return bool + */ + public function endsWith($needles) + { + return Str::endsWith($this->value, $needles); + } + + /** + * Determine if the string is an exact match with the given value. + * + * @param string $value + * @return bool + */ + public function exactly($value) + { + return $this->value === $value; + } + + /** + * Explode the string into an array. + * + * @param string $delimiter + * @param int $limit + * @return \Illuminate\Support\Collection + */ + public function explode($delimiter, $limit = PHP_INT_MAX) + { + return collect(explode($delimiter, $this->value, $limit)); + } + + /** + * Split a string using a regular expression or by length. + * + * @param string|int $pattern + * @param int $limit + * @param int $flags + * @return \Illuminate\Support\Collection + */ + public function split($pattern, $limit = -1, $flags = 0) + { + if (filter_var($pattern, FILTER_VALIDATE_INT) !== false) { + return collect(mb_str_split($this->value, $pattern)); + } + + $segments = preg_split($pattern, $this->value, $limit, $flags); + + return ! empty($segments) ? collect($segments) : collect(); + } + + /** + * Cap a string with a single instance of a given value. + * + * @param string $cap + * @return static + */ + public function finish($cap) + { + return new static(Str::finish($this->value, $cap)); + } + + /** + * Determine if a given string matches a given pattern. + * + * @param string|array $pattern + * @return bool + */ + public function is($pattern) + { + return Str::is($pattern, $this->value); + } + + /** + * Determine if a given string is 7 bit ASCII. + * + * @return bool + */ + public function isAscii() + { + return Str::isAscii($this->value); + } + + /** + * Determine if a given string is a valid UUID. + * + * @return bool + */ + public function isUuid() + { + return Str::isUuid($this->value); + } + + /** + * Determine if the given string is empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->value === ''; + } + + /** + * Determine if the given string is not empty. + * + * @return bool + */ + public function isNotEmpty() + { + return ! $this->isEmpty(); + } + + /** + * Convert a string to kebab case. + * + * @return static + */ + public function kebab() + { + return new static(Str::kebab($this->value)); + } + + /** + * Return the length of the given string. + * + * @param string $encoding + * @return int + */ + public function length($encoding = null) + { + return Str::length($this->value, $encoding); + } + + /** + * Limit the number of characters in a string. + * + * @param int $limit + * @param string $end + * @return static + */ + public function limit($limit = 100, $end = '...') + { + return new static(Str::limit($this->value, $limit, $end)); + } + + /** + * Convert the given string to lower-case. + * + * @return static + */ + public function lower() + { + return new static(Str::lower($this->value)); + } + + /** + * Convert GitHub flavored Markdown into HTML. + * + * @param array $options + * @return static + */ + public function markdown(array $options = []) + { + return new static(Str::markdown($this->value, $options)); + } + + /** + * Masks a portion of a string with a repeated character. + * + * @param string $character + * @param int $index + * @param int|null $length + * @param string $encoding + * @return static + */ + public function mask($character, $index, $length = null, $encoding = 'UTF-8') + { + return new static(Str::mask($this->value, $character, $index, $length, $encoding)); + } + + /** + * Get the string matching the given pattern. + * + * @param string $pattern + * @return static + */ + public function match($pattern) + { + return new static(Str::match($pattern, $this->value)); + } + + /** + * Get the string matching the given pattern. + * + * @param string $pattern + * @return \Illuminate\Support\Collection + */ + public function matchAll($pattern) + { + return Str::matchAll($pattern, $this->value); + } + + /** + * Determine if the string matches the given pattern. + * + * @param string $pattern + * @return bool + */ + public function test($pattern) + { + return $this->match($pattern)->isNotEmpty(); + } + + /** + * Pad both sides of the string with another. + * + * @param int $length + * @param string $pad + * @return static + */ + public function padBoth($length, $pad = ' ') + { + return new static(Str::padBoth($this->value, $length, $pad)); + } + + /** + * Pad the left side of the string with another. + * + * @param int $length + * @param string $pad + * @return static + */ + public function padLeft($length, $pad = ' ') + { + return new static(Str::padLeft($this->value, $length, $pad)); + } + + /** + * Pad the right side of the string with another. + * + * @param int $length + * @param string $pad + * @return static + */ + public function padRight($length, $pad = ' ') + { + return new static(Str::padRight($this->value, $length, $pad)); + } + + /** + * Parse a Class@method style callback into class and method. + * + * @param string|null $default + * @return array + */ + public function parseCallback($default = null) + { + return Str::parseCallback($this->value, $default); + } + + /** + * Call the given callback and return a new string. + * + * @param callable $callback + * @return static + */ + public function pipe(callable $callback) + { + return new static(call_user_func($callback, $this)); + } + + /** + * Get the plural form of an English word. + * + * @param int $count + * @return static + */ + public function plural($count = 2) + { + return new static(Str::plural($this->value, $count)); + } + + /** + * Pluralize the last word of an English, studly caps case string. + * + * @param int $count + * @return static + */ + public function pluralStudly($count = 2) + { + return new static(Str::pluralStudly($this->value, $count)); + } + + /** + * Prepend the given values to the string. + * + * @param array $values + * @return static + */ + public function prepend(...$values) + { + return new static(implode('', $values).$this->value); + } + + /** + * Remove any occurrence of the given string in the subject. + * + * @param string|array<string> $search + * @param bool $caseSensitive + * @return static + */ + public function remove($search, $caseSensitive = true) + { + return new static(Str::remove($search, $this->value, $caseSensitive)); + } + + /** + * Reverse the string. + * + * @return static + */ + public function reverse() + { + return new static(Str::reverse($this->value)); + } + + /** + * Repeat the string. + * + * @param int $times + * @return static + */ + public function repeat(int $times) + { + return new static(Str::repeat($this->value, $times)); + } + + /** + * Replace the given value in the given string. + * + * @param string|string[] $search + * @param string|string[] $replace + * @return static + */ + public function replace($search, $replace) + { + return new static(Str::replace($search, $replace, $this->value)); + } + + /** + * Replace a given value in the string sequentially with an array. + * + * @param string $search + * @param array $replace + * @return static + */ + public function replaceArray($search, array $replace) + { + return new static(Str::replaceArray($search, $replace, $this->value)); + } + + /** + * Replace the first occurrence of a given value in the string. + * + * @param string $search + * @param string $replace + * @return static + */ + public function replaceFirst($search, $replace) + { + return new static(Str::replaceFirst($search, $replace, $this->value)); + } + + /** + * Replace the last occurrence of a given value in the string. + * + * @param string $search + * @param string $replace + * @return static + */ + public function replaceLast($search, $replace) + { + return new static(Str::replaceLast($search, $replace, $this->value)); + } + + /** + * Replace the patterns matching the given regular expression. + * + * @param string $pattern + * @param \Closure|string $replace + * @param int $limit + * @return static + */ + public function replaceMatches($pattern, $replace, $limit = -1) + { + if ($replace instanceof Closure) { + return new static(preg_replace_callback($pattern, $replace, $this->value, $limit)); + } + + return new static(preg_replace($pattern, $replace, $this->value, $limit)); + } + + /** + * Parse input from a string to a collection, according to a format. + * + * @param string $format + * @return \Illuminate\Support\Collection + */ + public function scan($format) + { + return collect(sscanf($this->value, $format)); + } + + /** + * Begin a string with a single instance of a given value. + * + * @param string $prefix + * @return static + */ + public function start($prefix) + { + return new static(Str::start($this->value, $prefix)); + } + + /** + * Strip HTML and PHP tags from the given string. + * + * @param string $allowedTags + * @return static + */ + public function stripTags($allowedTags = null) + { + return new static(strip_tags($this->value, $allowedTags)); + } + + /** + * Convert the given string to upper-case. + * + * @return static + */ + public function upper() + { + return new static(Str::upper($this->value)); + } + + /** + * Convert the given string to title case. + * + * @return static + */ + public function title() + { + return new static(Str::title($this->value)); + } + + /** + * Convert the given string to title case for each word. + * + * @return static + */ + public function headline() + { + return new static(Str::headline($this->value)); + } + + /** + * Get the singular form of an English word. + * + * @return static + */ + public function singular() + { + return new static(Str::singular($this->value)); + } + + /** + * Generate a URL friendly "slug" from a given string. + * + * @param string $separator + * @param string|null $language + * @return static + */ + public function slug($separator = '-', $language = 'en') + { + return new static(Str::slug($this->value, $separator, $language)); + } + + /** + * Convert a string to snake case. + * + * @param string $delimiter + * @return static + */ + public function snake($delimiter = '_') + { + return new static(Str::snake($this->value, $delimiter)); + } + + /** + * Determine if a given string starts with a given substring. + * + * @param string|array $needles + * @return bool + */ + public function startsWith($needles) + { + return Str::startsWith($this->value, $needles); + } + + /** + * Convert a value to studly caps case. + * + * @return static + */ + public function studly() + { + return new static(Str::studly($this->value)); + } + + /** + * Returns the portion of the string specified by the start and length parameters. + * + * @param int $start + * @param int|null $length + * @return static + */ + public function substr($start, $length = null) + { + return new static(Str::substr($this->value, $start, $length)); + } + + /** + * Returns the number of substring occurrences. + * + * @param string $needle + * @param int|null $offset + * @param int|null $length + * @return int + */ + public function substrCount($needle, $offset = null, $length = null) + { + return Str::substrCount($this->value, $needle, $offset ?? 0, $length); + } + + /** + * Replace text within a portion of a string. + * + * @param string|array $replace + * @param array|int $offset + * @param array|int|null $length + * @return static + */ + public function substrReplace($replace, $offset = 0, $length = null) + { + return new static(Str::substrReplace($this->value, $replace, $offset, $length)); + } + + /** + * Swap multiple keywords in a string with other keywords. + * + * @param array $map + * @return static + */ + public function swap(array $map) + { + return new static(strtr($this->value, $map)); + } + + /** + * Trim the string of the given characters. + * + * @param string $characters + * @return static + */ + public function trim($characters = null) + { + return new static(trim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Left trim the string of the given characters. + * + * @param string $characters + * @return static + */ + public function ltrim($characters = null) + { + return new static(ltrim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Right trim the string of the given characters. + * + * @param string $characters + * @return static + */ + public function rtrim($characters = null) + { + return new static(rtrim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Make a string's first character uppercase. + * + * @return static + */ + public function ucfirst() + { + return new static(Str::ucfirst($this->value)); + } + + /** + * Split a string by uppercase characters. + * + * @return \Illuminate\Support\Collection + */ + public function ucsplit() + { + return collect(Str::ucsplit($this->value)); + } + + /** + * Execute the given callback if the string contains a given substring. + * + * @param string|array $needles + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenContains($needles, $callback, $default = null) + { + return $this->when($this->contains($needles), $callback, $default); + } + + /** + * Execute the given callback if the string contains all array values. + * + * @param array $needles + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenContainsAll(array $needles, $callback, $default = null) + { + return $this->when($this->containsAll($needles), $callback, $default); + } + + /** + * Execute the given callback if the string is empty. + * + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenEmpty($callback, $default = null) + { + return $this->when($this->isEmpty(), $callback, $default); + } + + /** + * Execute the given callback if the string is not empty. + * + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenNotEmpty($callback, $default = null) + { + return $this->when($this->isNotEmpty(), $callback, $default); + } + + /** + * Execute the given callback if the string ends with a given substring. + * + * @param string|array $needles + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenEndsWith($needles, $callback, $default = null) + { + return $this->when($this->endsWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string is an exact match with the given value. + * + * @param string $value + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenExactly($value, $callback, $default = null) + { + return $this->when($this->exactly($value), $callback, $default); + } + + /** + * Execute the given callback if the string matches a given pattern. + * + * @param string|array $pattern + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenIs($pattern, $callback, $default = null) + { + return $this->when($this->is($pattern), $callback, $default); + } + + /** + * Execute the given callback if the string is 7 bit ASCII. + * + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenIsAscii($callback, $default = null) + { + return $this->when($this->isAscii(), $callback, $default); + } + + /** + * Execute the given callback if the string is a valid UUID. + * + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenIsUuid($callback, $default = null) + { + return $this->when($this->isUuid(), $callback, $default); + } + + /** + * Execute the given callback if the string starts with a given substring. + * + * @param string|array $needles + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenStartsWith($needles, $callback, $default = null) + { + return $this->when($this->startsWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string matches the given pattern. + * + * @param string $pattern + * @param callable $callback + * @param callable|null $default + * @return static + */ + public function whenTest($pattern, $callback, $default = null) + { + return $this->when($this->test($pattern), $callback, $default); + } + + /** + * Limit the number of words in a string. + * + * @param int $words + * @param string $end + * @return static + */ + public function words($words = 100, $end = '...') + { + return new static(Str::words($this->value, $words, $end)); + } + + /** + * Get the number of words a string contains. + * + * @return int + */ + public function wordCount() + { + return str_word_count($this->value); + } + + /** + * Convert the string into a `HtmlString` instance. + * + * @return \Illuminate\Support\HtmlString + */ + public function toHtmlString() + { + return new HtmlString($this->value); + } + + /** + * Dump the string. + * + * @return $this + */ + public function dump() + { + VarDumper::dump($this->value); + + return $this; + } + + /** + * Dump the string and end the script. + * + * @return never + */ + public function dd() + { + $this->dump(); + + exit(1); + } + + /** + * Convert the object to a string when JSON encoded. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->__toString(); + } + + /** + * Proxy dynamic properties onto methods. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + return $this->{$key}(); + } + + /** + * Get the raw string value. + * + * @return string + */ + public function __toString() + { + return (string) $this->value; + } +} diff --git a/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php b/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php new file mode 100644 index 0000000000000000000000000000000000000000..d9661334ce01a733c23b4b18011941a99a21f92a --- /dev/null +++ b/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php @@ -0,0 +1,142 @@ +<?php + +namespace Illuminate\Support\Testing\Fakes; + +use Carbon\CarbonImmutable; +use Closure; +use Illuminate\Bus\Batch; +use Illuminate\Bus\BatchRepository; +use Illuminate\Bus\PendingBatch; +use Illuminate\Bus\UpdatedBatchJobCounts; +use Illuminate\Support\Facades\Facade; +use Illuminate\Support\Str; + +class BatchRepositoryFake implements BatchRepository +{ + /** + * Retrieve a list of batches. + * + * @param int $limit + * @param mixed $before + * @return \Illuminate\Bus\Batch[] + */ + public function get($limit, $before) + { + return []; + } + + /** + * Retrieve information about an existing batch. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function find(string $batchId) + { + // + } + + /** + * Store a new pending batch. + * + * @param \Illuminate\Bus\PendingBatch $batch + * @return \Illuminate\Bus\Batch + */ + public function store(PendingBatch $batch) + { + return new Batch( + new QueueFake(Facade::getFacadeApplication()), + $this, + (string) Str::orderedUuid(), + $batch->name, + count($batch->jobs), + count($batch->jobs), + 0, + [], + $batch->options, + CarbonImmutable::now(), + null, + null + ); + } + + /** + * Increment the total number of jobs within the batch. + * + * @param string $batchId + * @param int $amount + * @return void + */ + public function incrementTotalJobs(string $batchId, int $amount) + { + // + } + + /** + * Decrement the total number of pending jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function decrementPendingJobs(string $batchId, string $jobId) + { + return new UpdatedBatchJobCounts; + } + + /** + * Increment the total number of failed jobs for the batch. + * + * @param string $batchId + * @param string $jobId + * @return \Illuminate\Bus\UpdatedBatchJobCounts + */ + public function incrementFailedJobs(string $batchId, string $jobId) + { + return new UpdatedBatchJobCounts; + } + + /** + * Mark the batch that has the given ID as finished. + * + * @param string $batchId + * @return void + */ + public function markAsFinished(string $batchId) + { + // + } + + /** + * Cancel the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function cancel(string $batchId) + { + // + } + + /** + * Delete the batch that has the given ID. + * + * @param string $batchId + * @return void + */ + public function delete(string $batchId) + { + // + } + + /** + * Execute the given Closure within a storage specific transaction. + * + * @param \Closure $callback + * @return mixed + */ + public function transaction(Closure $callback) + { + return $callback(); + } +} diff --git a/src/Illuminate/Support/Testing/Fakes/BusFake.php b/src/Illuminate/Support/Testing/Fakes/BusFake.php index 7a5f0eb2f0d8a072bbdd5f79f42dc1fa30392f6c..122252d8f00a5244799593cc9aba2108b9df3ffd 100644 --- a/src/Illuminate/Support/Testing/Fakes/BusFake.php +++ b/src/Illuminate/Support/Testing/Fakes/BusFake.php @@ -3,16 +3,21 @@ namespace Illuminate\Support\Testing\Fakes; use Closure; -use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Bus\PendingBatch; +use Illuminate\Contracts\Bus\QueueingDispatcher; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; -class BusFake implements Dispatcher +class BusFake implements QueueingDispatcher { + use ReflectsClosures; + /** * The original Bus dispatcher implementation. * - * @var \Illuminate\Contracts\Bus\Dispatcher + * @var \Illuminate\Contracts\Bus\QueueingDispatcher */ protected $dispatcher; @@ -30,6 +35,13 @@ class BusFake implements Dispatcher */ protected $commands = []; + /** + * The commands that have been dispatched synchronously. + * + * @var array + */ + protected $commandsSync = []; + /** * The commands that have been dispatched after the response has been sent. * @@ -37,14 +49,21 @@ class BusFake implements Dispatcher */ protected $commandsAfterResponse = []; + /** + * The batches that have been dispatched. + * + * @var array + */ + protected $batches = []; + /** * Create a new bus fake instance. * - * @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher + * @param \Illuminate\Contracts\Bus\QueueingDispatcher $dispatcher * @param array|string $jobsToFake * @return void */ - public function __construct(Dispatcher $dispatcher, $jobsToFake = []) + public function __construct(QueueingDispatcher $dispatcher, $jobsToFake = []) { $this->dispatcher = $dispatcher; @@ -54,19 +73,24 @@ class BusFake implements Dispatcher /** * Assert if a job was dispatched based on a truth-test callback. * - * @param string $command + * @param string|\Closure $command * @param callable|int|null $callback * @return void */ public function assertDispatched($command, $callback = null) { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + if (is_numeric($callback)) { return $this->assertDispatchedTimes($command, $callback); } PHPUnit::assertTrue( $this->dispatched($command, $callback)->count() > 0 || - $this->dispatchedAfterResponse($command, $callback)->count() > 0, + $this->dispatchedAfterResponse($command, $callback)->count() > 0 || + $this->dispatchedSync($command, $callback)->count() > 0, "The expected [{$command}] job was not dispatched." ); } @@ -81,10 +105,11 @@ class BusFake implements Dispatcher public function assertDispatchedTimes($command, $times = 1) { $count = $this->dispatched($command)->count() + - $this->dispatchedAfterResponse($command)->count(); + $this->dispatchedAfterResponse($command)->count() + + $this->dispatchedSync($command)->count(); - PHPUnit::assertTrue( - $count === $times, + PHPUnit::assertSame( + $times, $count, "The expected [{$command}] job was pushed {$count} times instead of {$times} times." ); } @@ -92,35 +117,113 @@ class BusFake implements Dispatcher /** * Determine if a job was dispatched based on a truth-test callback. * - * @param string $command + * @param string|\Closure $command * @param callable|null $callback * @return void */ public function assertNotDispatched($command, $callback = null) { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + PHPUnit::assertTrue( $this->dispatched($command, $callback)->count() === 0 && - $this->dispatchedAfterResponse($command, $callback)->count() === 0, + $this->dispatchedAfterResponse($command, $callback)->count() === 0 && + $this->dispatchedSync($command, $callback)->count() === 0, "The unexpected [{$command}] job was dispatched." ); } /** - * Assert if a job was dispatched after the response was sent based on a truth-test callback. + * Assert that no jobs were dispatched. + * + * @return void + */ + public function assertNothingDispatched() + { + PHPUnit::assertEmpty($this->commands, 'Jobs were dispatched unexpectedly.'); + } + + /** + * Assert if a job was explicitly dispatched synchronously based on a truth-test callback. + * + * @param string|\Closure $command + * @param callable|int|null $callback + * @return void + */ + public function assertDispatchedSync($command, $callback = null) + { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + + if (is_numeric($callback)) { + return $this->assertDispatchedSyncTimes($command, $callback); + } + + PHPUnit::assertTrue( + $this->dispatchedSync($command, $callback)->count() > 0, + "The expected [{$command}] job was not dispatched synchronously." + ); + } + + /** + * Assert if a job was pushed synchronously a number of times. * * @param string $command + * @param int $times + * @return void + */ + public function assertDispatchedSyncTimes($command, $times = 1) + { + $count = $this->dispatchedSync($command)->count(); + + PHPUnit::assertSame( + $times, $count, + "The expected [{$command}] job was synchronously pushed {$count} times instead of {$times} times." + ); + } + + /** + * Determine if a job was dispatched based on a truth-test callback. + * + * @param string|\Closure $command + * @param callable|null $callback + * @return void + */ + public function assertNotDispatchedSync($command, $callback = null) + { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + + PHPUnit::assertCount( + 0, $this->dispatchedSync($command, $callback), + "The unexpected [{$command}] job was dispatched synchronously." + ); + } + + /** + * Assert if a job was dispatched after the response was sent based on a truth-test callback. + * + * @param string|\Closure $command * @param callable|int|null $callback * @return void */ public function assertDispatchedAfterResponse($command, $callback = null) { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + if (is_numeric($callback)) { return $this->assertDispatchedAfterResponseTimes($command, $callback); } PHPUnit::assertTrue( $this->dispatchedAfterResponse($command, $callback)->count() > 0, - "The expected [{$command}] job was not dispatched for after sending the response." + "The expected [{$command}] job was not dispatched after sending the response." ); } @@ -133,8 +236,10 @@ class BusFake implements Dispatcher */ public function assertDispatchedAfterResponseTimes($command, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->dispatchedAfterResponse($command)->count()) === $times, + $count = $this->dispatchedAfterResponse($command)->count(); + + PHPUnit::assertSame( + $times, $count, "The expected [{$command}] job was pushed {$count} times instead of {$times} times." ); } @@ -142,15 +247,182 @@ class BusFake implements Dispatcher /** * Determine if a job was dispatched based on a truth-test callback. * - * @param string $command + * @param string|\Closure $command * @param callable|null $callback * @return void */ public function assertNotDispatchedAfterResponse($command, $callback = null) + { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + + PHPUnit::assertCount( + 0, $this->dispatchedAfterResponse($command, $callback), + "The unexpected [{$command}] job was dispatched after sending the response." + ); + } + + /** + * Assert if a chain of jobs was dispatched. + * + * @param array $expectedChain + * @return void + */ + public function assertChained(array $expectedChain) + { + $command = $expectedChain[0]; + + $expectedChain = array_slice($expectedChain, 1); + + $callback = null; + + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } elseif (! is_string($command)) { + $instance = $command; + + $command = get_class($instance); + + $callback = function ($job) use ($instance) { + return serialize($this->resetChainPropertiesToDefaults($job)) === serialize($instance); + }; + } + + PHPUnit::assertTrue( + $this->dispatched($command, $callback)->isNotEmpty(), + "The expected [{$command}] job was not dispatched." + ); + + PHPUnit::assertTrue( + collect($expectedChain)->isNotEmpty(), + 'The expected chain can not be empty.' + ); + + $this->isChainOfObjects($expectedChain) + ? $this->assertDispatchedWithChainOfObjects($command, $expectedChain, $callback) + : $this->assertDispatchedWithChainOfClasses($command, $expectedChain, $callback); + } + + /** + * Reset the chain properties to their default values on the job. + * + * @param mixed $job + * @return mixed + */ + protected function resetChainPropertiesToDefaults($job) + { + return tap(clone $job, function ($job) { + $job->chainConnection = null; + $job->chainQueue = null; + $job->chainCatchCallbacks = null; + $job->chained = []; + }); + } + + /** + * Assert if a job was dispatched with an empty chain based on a truth-test callback. + * + * @param string|\Closure $command + * @param callable|null $callback + * @return void + */ + public function assertDispatchedWithoutChain($command, $callback = null) + { + if ($command instanceof Closure) { + [$command, $callback] = [$this->firstClosureParameterType($command), $command]; + } + + PHPUnit::assertTrue( + $this->dispatched($command, $callback)->isNotEmpty(), + "The expected [{$command}] job was not dispatched." + ); + + $this->assertDispatchedWithChainOfClasses($command, [], $callback); + } + + /** + * Assert if a job was dispatched with chained jobs based on a truth-test callback. + * + * @param string $command + * @param array $expectedChain + * @param callable|null $callback + * @return void + */ + protected function assertDispatchedWithChainOfObjects($command, $expectedChain, $callback) + { + $chain = collect($expectedChain)->map(function ($job) { + return serialize($job); + })->all(); + + PHPUnit::assertTrue( + $this->dispatched($command, $callback)->filter(function ($job) use ($chain) { + return $job->chained == $chain; + })->isNotEmpty(), + 'The expected chain was not dispatched.' + ); + } + + /** + * Assert if a job was dispatched with chained jobs based on a truth-test callback. + * + * @param string $command + * @param array $expectedChain + * @param callable|null $callback + * @return void + */ + protected function assertDispatchedWithChainOfClasses($command, $expectedChain, $callback) + { + $matching = $this->dispatched($command, $callback)->map->chained->map(function ($chain) { + return collect($chain)->map(function ($job) { + return get_class(unserialize($job)); + }); + })->filter(function ($chain) use ($expectedChain) { + return $chain->all() === $expectedChain; + }); + + PHPUnit::assertTrue( + $matching->isNotEmpty(), 'The expected chain was not dispatched.' + ); + } + + /** + * Determine if the given chain is entirely composed of objects. + * + * @param array $chain + * @return bool + */ + protected function isChainOfObjects($chain) + { + return ! collect($chain)->contains(function ($job) { + return ! is_object($job); + }); + } + + /** + * Assert if a batch was dispatched based on a truth-test callback. + * + * @param callable $callback + * @return void + */ + public function assertBatched(callable $callback) { PHPUnit::assertTrue( - $this->dispatchedAfterResponse($command, $callback)->count() === 0, - "The unexpected [{$command}] job was dispatched for after sending the response." + $this->batched($callback)->count() > 0, + 'The expected batch was not dispatched.' + ); + } + + /** + * Assert the number of batches that have been dispatched. + * + * @param int $count + * @return void + */ + public function assertBatchCount($count) + { + PHPUnit::assertCount( + $count, $this->batches, ); } @@ -176,6 +448,28 @@ class BusFake implements Dispatcher }); } + /** + * Get all of the jobs dispatched synchronously matching a truth-test callback. + * + * @param string $command + * @param callable|null $callback + * @return \Illuminate\Support\Collection + */ + public function dispatchedSync(string $command, $callback = null) + { + if (! $this->hasDispatchedSync($command)) { + return collect(); + } + + $callback = $callback ?: function () { + return true; + }; + + return collect($this->commandsSync[$command])->filter(function ($command) use ($callback) { + return $callback($command); + }); + } + /** * Get all of the jobs dispatched after the response was sent matching a truth-test callback. * @@ -198,6 +492,23 @@ class BusFake implements Dispatcher }); } + /** + * Get all of the pending batches matching a truth-test callback. + * + * @param callable $callback + * @return \Illuminate\Support\Collection + */ + public function batched(callable $callback) + { + if (empty($this->batches)) { + return collect(); + } + + return collect($this->batches)->filter(function ($batch) use ($callback) { + return $callback($batch); + }); + } + /** * Determine if there are any stored commands for a given class. * @@ -209,6 +520,17 @@ class BusFake implements Dispatcher return isset($this->commands[$command]) && ! empty($this->commands[$command]); } + /** + * Determine if there are any stored commands for a given class. + * + * @param string $command + * @return bool + */ + public function hasDispatchedSync($command) + { + return isset($this->commandsSync[$command]) && ! empty($this->commandsSync[$command]); + } + /** * Determine if there are any stored commands for a given class. * @@ -235,6 +557,24 @@ class BusFake implements Dispatcher } } + /** + * Dispatch a command to its appropriate handler in the current process. + * + * Queueable jobs will be dispatched to the "sync" queue. + * + * @param mixed $command + * @param mixed $handler + * @return mixed + */ + public function dispatchSync($command, $handler = null) + { + if ($this->shouldFakeJob($command)) { + $this->commandsSync[get_class($command)][] = $command; + } else { + return $this->dispatcher->dispatchSync($command, $handler); + } + } + /** * Dispatch a command to its appropriate handler in the current process. * @@ -251,6 +591,21 @@ class BusFake implements Dispatcher } } + /** + * Dispatch a command to its appropriate handler behind a queue. + * + * @param mixed $command + * @return mixed + */ + public function dispatchToQueue($command) + { + if ($this->shouldFakeJob($command)) { + $this->commands[get_class($command)][] = $command; + } else { + return $this->dispatcher->dispatchToQueue($command); + } + } + /** * Dispatch a command to its appropriate handler. * @@ -267,7 +622,55 @@ class BusFake implements Dispatcher } /** - * Determine if an command should be faked or actually dispatched. + * Create a new chain of queueable jobs. + * + * @param \Illuminate\Support\Collection|array $jobs + * @return \Illuminate\Foundation\Bus\PendingChain + */ + public function chain($jobs) + { + $jobs = Collection::wrap($jobs); + + return new PendingChainFake($this, $jobs->shift(), $jobs->toArray()); + } + + /** + * Attempt to find the batch with the given ID. + * + * @param string $batchId + * @return \Illuminate\Bus\Batch|null + */ + public function findBatch(string $batchId) + { + // + } + + /** + * Create a new batch of queueable jobs. + * + * @param \Illuminate\Support\Collection|array $jobs + * @return \Illuminate\Bus\PendingBatch + */ + public function batch($jobs) + { + return new PendingBatchFake($this, Collection::wrap($jobs)); + } + + /** + * Record the fake pending batch dispatch. + * + * @param \Illuminate\Bus\PendingBatch $pendingBatch + * @return \Illuminate\Bus\Batch + */ + public function recordPendingBatch(PendingBatch $pendingBatch) + { + $this->batches[] = $pendingBatch; + + return (new BatchRepositoryFake)->store($pendingBatch); + } + + /** + * Determine if a command should be faked or actually dispatched. * * @param mixed $command * @return bool diff --git a/src/Illuminate/Support/Testing/Fakes/EventFake.php b/src/Illuminate/Support/Testing/Fakes/EventFake.php index 7fe7ccc898744e707ced4e0705a549670c3640be..436173e9d3ae2f8ebe4e8f6fd197da3ec32e09f4 100644 --- a/src/Illuminate/Support/Testing/Fakes/EventFake.php +++ b/src/Illuminate/Support/Testing/Fakes/EventFake.php @@ -5,10 +5,15 @@ namespace Illuminate\Support\Testing\Fakes; use Closure; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; +use ReflectionFunction; class EventFake implements Dispatcher { + use ReflectsClosures; + /** * The original event dispatcher. * @@ -44,15 +49,55 @@ class EventFake implements Dispatcher $this->eventsToFake = Arr::wrap($eventsToFake); } + /** + * Assert if an event has a listener attached to it. + * + * @param string $expectedEvent + * @param string $expectedListener + * @return void + */ + public function assertListening($expectedEvent, $expectedListener) + { + foreach ($this->dispatcher->getListeners($expectedEvent) as $listenerClosure) { + $actualListener = (new ReflectionFunction($listenerClosure)) + ->getStaticVariables()['listener']; + + if (is_string($actualListener) && Str::endsWith($actualListener, '@handle')) { + $actualListener = Str::parseCallback($actualListener)[0]; + } + + if ($actualListener === $expectedListener || + ($actualListener instanceof Closure && + $expectedListener === Closure::class)) { + PHPUnit::assertTrue(true); + + return; + } + } + + PHPUnit::assertTrue( + false, + sprintf( + 'Event [%s] does not have the [%s] listener attached to it', + $expectedEvent, + print_r($expectedListener, true) + ) + ); + } + /** * Assert if an event was dispatched based on a truth-test callback. * - * @param string $event + * @param string|\Closure $event * @param callable|int|null $callback * @return void */ public function assertDispatched($event, $callback = null) { + if ($event instanceof Closure) { + [$event, $callback] = [$this->firstClosureParameterType($event), $event]; + } + if (is_int($callback)) { return $this->assertDispatchedTimes($event, $callback); } @@ -64,7 +109,7 @@ class EventFake implements Dispatcher } /** - * Assert if a event was dispatched a number of times. + * Assert if an event was dispatched a number of times. * * @param string $event * @param int $times @@ -72,8 +117,10 @@ class EventFake implements Dispatcher */ public function assertDispatchedTimes($event, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->dispatched($event)->count()) === $times, + $count = $this->dispatched($event)->count(); + + PHPUnit::assertSame( + $times, $count, "The expected [{$event}] event was dispatched {$count} times instead of {$times} times." ); } @@ -81,18 +128,37 @@ class EventFake implements Dispatcher /** * Determine if an event was dispatched based on a truth-test callback. * - * @param string $event + * @param string|\Closure $event * @param callable|null $callback * @return void */ public function assertNotDispatched($event, $callback = null) { - PHPUnit::assertTrue( - $this->dispatched($event, $callback)->count() === 0, + if ($event instanceof Closure) { + [$event, $callback] = [$this->firstClosureParameterType($event), $event]; + } + + PHPUnit::assertCount( + 0, $this->dispatched($event, $callback), "The unexpected [{$event}] event was dispatched." ); } + /** + * Assert that no events were dispatched. + * + * @return void + */ + public function assertNothingDispatched() + { + $count = count(Arr::flatten($this->events)); + + PHPUnit::assertSame( + 0, $count, + "{$count} unexpected events were dispatched." + ); + } + /** * Get all of the events matching a truth-test callback. * @@ -129,11 +195,11 @@ class EventFake implements Dispatcher /** * Register an event listener with the dispatcher. * - * @param string|array $events + * @param \Closure|string|array $events * @param mixed $listener * @return void */ - public function listen($events, $listener) + public function listen($events, $listener = null) { $this->dispatcher->listen($events, $listener); } @@ -250,7 +316,7 @@ class EventFake implements Dispatcher * * @param string|object $event * @param mixed $payload - * @return void + * @return array|null */ public function until($event, $payload = []) { diff --git a/src/Illuminate/Support/Testing/Fakes/MailFake.php b/src/Illuminate/Support/Testing/Fakes/MailFake.php index 48fa7b955358a0305cb6d157dbc022f865f95123..fff5f8fcb78360dcdc9d3bfa723c77484d89d6b0 100644 --- a/src/Illuminate/Support/Testing/Fakes/MailFake.php +++ b/src/Illuminate/Support/Testing/Fakes/MailFake.php @@ -2,14 +2,26 @@ namespace Illuminate\Support\Testing\Fakes; +use Closure; +use Illuminate\Contracts\Mail\Factory; use Illuminate\Contracts\Mail\Mailable; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Mail\MailQueue; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; -class MailFake implements Mailer, MailQueue +class MailFake implements Factory, Mailer, MailQueue { + use ReflectsClosures; + + /** + * The mailer currently being used to send a message. + * + * @var string + */ + protected $currentMailer; + /** * All of the mailables that have been sent. * @@ -27,12 +39,14 @@ class MailFake implements Mailer, MailQueue /** * Assert if a mailable was sent based on a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|int|null $callback * @return void */ public function assertSent($mailable, $callback = null) { + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + if (is_numeric($callback)) { return $this->assertSentTimes($mailable, $callback); } @@ -58,27 +72,55 @@ class MailFake implements Mailer, MailQueue */ protected function assertSentTimes($mailable, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->sent($mailable)->count()) === $times, + $count = $this->sent($mailable)->count(); + + PHPUnit::assertSame( + $times, $count, "The expected [{$mailable}] mailable was sent {$count} times instead of {$times} times." ); } + /** + * Determine if a mailable was not sent or queued to be sent based on a truth-test callback. + * + * @param string|\Closure $mailable + * @param callable|null $callback + * @return void + */ + public function assertNotOutgoing($mailable, $callback = null) + { + $this->assertNotSent($mailable, $callback); + $this->assertNotQueued($mailable, $callback); + } + /** * Determine if a mailable was not sent based on a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|null $callback * @return void */ public function assertNotSent($mailable, $callback = null) { - PHPUnit::assertTrue( - $this->sent($mailable, $callback)->count() === 0, + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + + PHPUnit::assertCount( + 0, $this->sent($mailable, $callback), "The unexpected [{$mailable}] mailable was sent." ); } + /** + * Assert that no mailables were sent or queued to be sent. + * + * @return void + */ + public function assertNothingOutgoing() + { + $this->assertNothingSent(); + $this->assertNothingQueued(); + } + /** * Assert that no mailables were sent. * @@ -96,12 +138,14 @@ class MailFake implements Mailer, MailQueue /** * Assert if a mailable was queued based on a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|int|null $callback * @return void */ public function assertQueued($mailable, $callback = null) { + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + if (is_numeric($callback)) { return $this->assertQueuedTimes($mailable, $callback); } @@ -121,8 +165,10 @@ class MailFake implements Mailer, MailQueue */ protected function assertQueuedTimes($mailable, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->queued($mailable)->count()) === $times, + $count = $this->queued($mailable)->count(); + + PHPUnit::assertSame( + $times, $count, "The expected [{$mailable}] mailable was queued {$count} times instead of {$times} times." ); } @@ -130,14 +176,16 @@ class MailFake implements Mailer, MailQueue /** * Determine if a mailable was not queued based on a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|null $callback * @return void */ public function assertNotQueued($mailable, $callback = null) { - PHPUnit::assertTrue( - $this->queued($mailable, $callback)->count() === 0, + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + + PHPUnit::assertCount( + 0, $this->queued($mailable, $callback), "The unexpected [{$mailable}] mailable was queued." ); } @@ -159,12 +207,14 @@ class MailFake implements Mailer, MailQueue /** * Get all of the mailables matching a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|null $callback * @return \Illuminate\Support\Collection */ public function sent($mailable, $callback = null) { + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + if (! $this->hasSent($mailable)) { return collect(); } @@ -192,12 +242,14 @@ class MailFake implements Mailer, MailQueue /** * Get all of the queued mailables matching a truth-test callback. * - * @param string $mailable + * @param string|\Closure $mailable * @param callable|null $callback * @return \Illuminate\Support\Collection */ public function queued($mailable, $callback = null) { + [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); + if (! $this->hasQueued($mailable)) { return collect(); } @@ -248,6 +300,19 @@ class MailFake implements Mailer, MailQueue }); } + /** + * Get a mailer instance by name. + * + * @param string|null $name + * @return \Illuminate\Contracts\Mail\Mailer + */ + public function mailer($name = null) + { + $this->currentMailer = $name; + + return $this; + } + /** * Begin the process of mailing a mailable class instance. * @@ -285,9 +350,9 @@ class MailFake implements Mailer, MailQueue /** * Send a new message using a view. * - * @param string|array $view + * @param \Illuminate\Contracts\Mail\Mailable|string|array $view * @param array $data - * @param \Closure|string $callback + * @param \Closure|string|null $callback * @return void */ public function send($view, array $data = [], $callback = null) @@ -296,10 +361,14 @@ class MailFake implements Mailer, MailQueue return; } + $view->mailer($this->currentMailer); + if ($view instanceof ShouldQueue) { return $this->queue($view, $data); } + $this->currentMailer = null; + $this->mailables[] = $view; } @@ -316,6 +385,10 @@ class MailFake implements Mailer, MailQueue return; } + $view->mailer($this->currentMailer); + + $this->currentMailer = null; + $this->queuedMailables[] = $view; } @@ -324,7 +397,7 @@ class MailFake implements Mailer, MailQueue * * @param \DateTimeInterface|\DateInterval|int $delay * @param \Illuminate\Contracts\Mail\Mailable|string|array $view - * @param string $queue + * @param string|null $queue * @return mixed */ public function later($delay, $view, $queue = null) @@ -341,4 +414,32 @@ class MailFake implements Mailer, MailQueue { return []; } + + /** + * Infer mailable class using reflection if a typehinted closure is passed to assertion. + * + * @param string|\Closure $mailable + * @param callable|null $callback + * @return array + */ + protected function prepareMailableAndCallback($mailable, $callback) + { + if ($mailable instanceof Closure) { + return [$this->firstClosureParameterType($mailable), $mailable]; + } + + return [$mailable, $callback]; + } + + /** + * Forget all of the resolved mailer instances. + * + * @return $this + */ + public function forgetMailers() + { + $this->currentMailer = null; + + return $this; + } } diff --git a/src/Illuminate/Support/Testing/Fakes/NotificationFake.php b/src/Illuminate/Support/Testing/Fakes/NotificationFake.php index 95edd71a94eca12d750cc0dd955b9193ced95394..c7b12f42d47c1005e2f59489b18967881053a019 100644 --- a/src/Illuminate/Support/Testing/Fakes/NotificationFake.php +++ b/src/Illuminate/Support/Testing/Fakes/NotificationFake.php @@ -2,18 +2,21 @@ namespace Illuminate\Support\Testing\Fakes; +use Closure; use Exception; use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Illuminate\Contracts\Notifications\Factory as NotificationFactory; use Illuminate\Contracts\Translation\HasLocalePreference; +use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; class NotificationFake implements NotificationDispatcher, NotificationFactory { - use Macroable; + use Macroable, ReflectsClosures; /** * All of the notifications that have been sent. @@ -29,11 +32,25 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory */ public $locale; + /** + * Assert if a notification was sent on-demand based on a truth-test callback. + * + * @param string|\Closure $notification + * @param callable|null $callback + * @return void + * + * @throws \Exception + */ + public function assertSentOnDemand($notification, $callback = null) + { + $this->assertSentTo(new AnonymousNotifiable, $notification, $callback); + } + /** * Assert if a notification was sent based on a truth-test callback. * * @param mixed $notifiable - * @param string $notification + * @param string|\Closure $notification * @param callable|null $callback * @return void * @@ -53,6 +70,10 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory return; } + if ($notification instanceof Closure) { + [$notification, $callback] = [$this->firstClosureParameterType($notification), $notification]; + } + if (is_numeric($callback)) { return $this->assertSentToTimes($notifiable, $notification, $callback); } @@ -63,6 +84,18 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory ); } + /** + * Assert if a notification was sent on-demand a number of times. + * + * @param string $notification + * @param int $times + * @return void + */ + public function assertSentOnDemandTimes($notification, $times = 1) + { + return $this->assertSentToTimes(new AnonymousNotifiable, $notification, $times); + } + /** * Assert if a notification was sent a number of times. * @@ -73,8 +106,10 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory */ public function assertSentToTimes($notifiable, $notification, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->sent($notifiable, $notification)->count()) === $times, + $count = $this->sent($notifiable, $notification)->count(); + + PHPUnit::assertSame( + $times, $count, "Expected [{$notification}] to be sent {$times} times, but was sent {$count} times." ); } @@ -83,7 +118,7 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory * Determine if a notification was sent based on a truth-test callback. * * @param mixed $notifiable - * @param string $notification + * @param string|\Closure $notification * @param callable|null $callback * @return void * @@ -103,8 +138,12 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory return; } - PHPUnit::assertTrue( - $this->sent($notifiable, $notification, $callback)->count() === 0, + if ($notification instanceof Closure) { + [$notification, $callback] = [$this->firstClosureParameterType($notification), $notification]; + } + + PHPUnit::assertCount( + 0, $this->sent($notifiable, $notification, $callback), "The unexpected [{$notification}] notification was sent." ); } @@ -122,11 +161,11 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory /** * Assert the total amount of times a notification was sent. * - * @param int $expectedCount * @param string $notification + * @param int $expectedCount * @return void */ - public function assertTimesSent($expectedCount, $notification) + public function assertSentTimes($notification, $expectedCount) { $actualCount = collect($this->notifications) ->flatten(1) @@ -140,6 +179,20 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory ); } + /** + * Assert the total amount of times a notification was sent. + * + * @param int $expectedCount + * @param string $notification + * @return void + * + * @deprecated Use the assertSentTimes method instead + */ + public function assertTimesSent($expectedCount, $notification) + { + $this->assertSentTimes($notification, $expectedCount); + } + /** * Get all of the notifications matching a truth-test callback. * @@ -198,7 +251,7 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory */ public function send($notifiables, $notification) { - return $this->sendNow($notifiables, $notification); + $this->sendNow($notifiables, $notification); } /** @@ -220,9 +273,24 @@ class NotificationFake implements NotificationDispatcher, NotificationFactory $notification->id = Str::uuid()->toString(); } + $notifiableChannels = $channels ?: $notification->via($notifiable); + + if (method_exists($notification, 'shouldSend')) { + $notifiableChannels = array_filter( + $notifiableChannels, + function ($channel) use ($notification, $notifiable) { + return $notification->shouldSend($notifiable, $channel) !== false; + } + ); + + if (empty($notifiableChannels)) { + continue; + } + } + $this->notifications[get_class($notifiable)][$notifiable->getKey()][get_class($notification)][] = [ 'notification' => $notification, - 'channels' => $channels ?: $notification->via($notifiable), + 'channels' => $notifiableChannels, 'notifiable' => $notifiable, 'locale' => $notification->locale ?? $this->locale ?? value(function () use ($notifiable) { if ($notifiable instanceof HasLocalePreference) { diff --git a/src/Illuminate/Support/Testing/Fakes/PendingBatchFake.php b/src/Illuminate/Support/Testing/Fakes/PendingBatchFake.php new file mode 100644 index 0000000000000000000000000000000000000000..c60b4b50ba71bb4a54832fcd7a3e6ac01f0643c0 --- /dev/null +++ b/src/Illuminate/Support/Testing/Fakes/PendingBatchFake.php @@ -0,0 +1,39 @@ +<?php + +namespace Illuminate\Support\Testing\Fakes; + +use Illuminate\Bus\PendingBatch; +use Illuminate\Support\Collection; + +class PendingBatchFake extends PendingBatch +{ + /** + * The fake bus instance. + * + * @var \Illuminate\Support\Testing\Fakes\BusFake + */ + protected $bus; + + /** + * Create a new pending batch instance. + * + * @param \Illuminate\Support\Testing\Fakes\BusFake $bus + * @param \Illuminate\Support\Collection $jobs + * @return void + */ + public function __construct(BusFake $bus, Collection $jobs) + { + $this->bus = $bus; + $this->jobs = $jobs; + } + + /** + * Dispatch the batch. + * + * @return \Illuminate\Bus\Batch + */ + public function dispatch() + { + return $this->bus->recordPendingBatch($this); + } +} diff --git a/src/Illuminate/Support/Testing/Fakes/PendingChainFake.php b/src/Illuminate/Support/Testing/Fakes/PendingChainFake.php new file mode 100644 index 0000000000000000000000000000000000000000..533c6498b331352c9a5358e1d33170a7fde3e340 --- /dev/null +++ b/src/Illuminate/Support/Testing/Fakes/PendingChainFake.php @@ -0,0 +1,56 @@ +<?php + +namespace Illuminate\Support\Testing\Fakes; + +use Closure; +use Illuminate\Foundation\Bus\PendingChain; +use Illuminate\Queue\CallQueuedClosure; + +class PendingChainFake extends PendingChain +{ + /** + * The fake bus instance. + * + * @var \Illuminate\Support\Testing\Fakes\BusFake + */ + protected $bus; + + /** + * Create a new pending chain instance. + * + * @param \Illuminate\Support\Testing\Fakes\BusFake $bus + * @param mixed $job + * @param array $chain + * @return void + */ + public function __construct(BusFake $bus, $job, $chain) + { + $this->bus = $bus; + $this->job = $job; + $this->chain = $chain; + } + + /** + * Dispatch the job with the given arguments. + * + * @return \Illuminate\Foundation\Bus\PendingDispatch + */ + public function dispatch() + { + if (is_string($this->job)) { + $firstJob = new $this->job(...func_get_args()); + } elseif ($this->job instanceof Closure) { + $firstJob = CallQueuedClosure::create($this->job); + } else { + $firstJob = $this->job; + } + + $firstJob->allOnConnection($this->connection); + $firstJob->allOnQueue($this->queue); + $firstJob->chain($this->chain); + $firstJob->delay($this->delay); + $firstJob->chainCatchCallbacks = $this->catchCallbacks(); + + return $this->bus->dispatch($firstJob); + } +} diff --git a/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php b/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php index 223dd44334a2ae5101c4d09729ea9571216a3ad8..52251301ceb9c49bd30a15d664690e296bdd9fa0 100644 --- a/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php +++ b/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php @@ -22,22 +22,11 @@ class PendingMailFake extends PendingMail * Send a new mailable message instance. * * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed + * @return void */ public function send(Mailable $mailable) { - return $this->sendNow($mailable); - } - - /** - * Send a mailable message immediately. - * - * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed - */ - public function sendNow(Mailable $mailable) - { - return $this->mailer->send($this->fill($mailable)); + $this->mailer->send($this->fill($mailable)); } /** diff --git a/src/Illuminate/Support/Testing/Fakes/QueueFake.php b/src/Illuminate/Support/Testing/Fakes/QueueFake.php index 30bf327de758839fa7a44985ebd127fba5cbb654..d37cd67237a6e07a558beb3335fbbc12bc392f4d 100644 --- a/src/Illuminate/Support/Testing/Fakes/QueueFake.php +++ b/src/Illuminate/Support/Testing/Fakes/QueueFake.php @@ -3,12 +3,16 @@ namespace Illuminate\Support\Testing\Fakes; use BadMethodCallException; +use Closure; use Illuminate\Contracts\Queue\Queue; use Illuminate\Queue\QueueManager; +use Illuminate\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; class QueueFake extends QueueManager implements Queue { + use ReflectsClosures; + /** * All of the jobs that have been pushed. * @@ -19,12 +23,16 @@ class QueueFake extends QueueManager implements Queue /** * Assert if a job was pushed based on a truth-test callback. * - * @param string $job + * @param string|\Closure $job * @param callable|int|null $callback * @return void */ public function assertPushed($job, $callback = null) { + if ($job instanceof Closure) { + [$job, $callback] = [$this->firstClosureParameterType($job), $job]; + } + if (is_numeric($callback)) { return $this->assertPushedTimes($job, $callback); } @@ -44,8 +52,10 @@ class QueueFake extends QueueManager implements Queue */ protected function assertPushedTimes($job, $times = 1) { - PHPUnit::assertTrue( - ($count = $this->pushed($job)->count()) === $times, + $count = $this->pushed($job)->count(); + + PHPUnit::assertSame( + $times, $count, "The expected [{$job}] job was pushed {$count} times instead of {$times} times." ); } @@ -54,13 +64,17 @@ class QueueFake extends QueueManager implements Queue * Assert if a job was pushed based on a truth-test callback. * * @param string $queue - * @param string $job + * @param string|\Closure $job * @param callable|null $callback * @return void */ public function assertPushedOn($queue, $job, $callback = null) { - return $this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) { + if ($job instanceof Closure) { + [$job, $callback] = [$this->firstClosureParameterType($job), $job]; + } + + $this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) { if ($pushedQueue !== $queue) { return false; } @@ -172,14 +186,18 @@ class QueueFake extends QueueManager implements Queue /** * Determine if a job was pushed based on a truth-test callback. * - * @param string $job + * @param string|\Closure $job * @param callable|null $callback * @return void */ public function assertNotPushed($job, $callback = null) { - PHPUnit::assertTrue( - $this->pushed($job, $callback)->count() === 0, + if ($job instanceof Closure) { + [$job, $callback] = [$this->firstClosureParameterType($job), $job]; + } + + PHPUnit::assertCount( + 0, $this->pushed($job, $callback), "The unexpected [{$job}] job was pushed." ); } @@ -254,7 +272,7 @@ class QueueFake extends QueueManager implements Queue /** * Push a new job onto the queue. * - * @param string $job + * @param string|object $job * @param mixed $data * @param string|null $queue * @return mixed @@ -284,7 +302,7 @@ class QueueFake extends QueueManager implements Queue * Push a new job onto the queue after a delay. * * @param \DateTimeInterface|\DateInterval|int $delay - * @param string $job + * @param string|object $job * @param mixed $data * @param string|null $queue * @return mixed @@ -298,7 +316,7 @@ class QueueFake extends QueueManager implements Queue * Push a new job onto the queue. * * @param string $queue - * @param string $job + * @param string|object $job * @param mixed $data * @return mixed */ @@ -312,7 +330,7 @@ class QueueFake extends QueueManager implements Queue * * @param string $queue * @param \DateTimeInterface|\DateInterval|int $delay - * @param string $job + * @param string|object $job * @param mixed $data * @return mixed */ diff --git a/src/Illuminate/Support/Timebox.php b/src/Illuminate/Support/Timebox.php new file mode 100644 index 0000000000000000000000000000000000000000..32fd607db361c67de707bb8e9a0fbbea18f5f91b --- /dev/null +++ b/src/Illuminate/Support/Timebox.php @@ -0,0 +1,70 @@ +<?php + +namespace Illuminate\Support; + +class Timebox +{ + /** + * Indicates if the timebox is allowed to return early. + * + * @var bool + */ + public $earlyReturn = false; + + /** + * Invoke the given callback within the specified timebox minimum. + * + * @param callable $callback + * @param int $microseconds + * @return mixed + */ + public function call(callable $callback, int $microseconds) + { + $start = microtime(true); + + $result = $callback($this); + + $remainder = $microseconds - ((microtime(true) - $start) * 1000000); + + if (! $this->earlyReturn && $remainder > 0) { + $this->usleep($remainder); + } + + return $result; + } + + /** + * Indicate that the timebox can return early. + * + * @return $this + */ + public function returnEarly() + { + $this->earlyReturn = true; + + return $this; + } + + /** + * Indicate that the timebox cannot return early. + * + * @return $this + */ + public function dontReturnEarly() + { + $this->earlyReturn = false; + + return $this; + } + + /** + * Sleep for the specified number of microseconds. + * + * @param $microseconds + * @return void + */ + protected function usleep($microseconds) + { + usleep($microseconds); + } +} diff --git a/src/Illuminate/Support/Traits/Conditionable.php b/src/Illuminate/Support/Traits/Conditionable.php new file mode 100644 index 0000000000000000000000000000000000000000..798082794f1a4643a176144ed156299d85f0cd8d --- /dev/null +++ b/src/Illuminate/Support/Traits/Conditionable.php @@ -0,0 +1,44 @@ +<?php + +namespace Illuminate\Support\Traits; + +trait Conditionable +{ + /** + * Apply the callback if the given "value" is truthy. + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function when($value, $callback, $default = null) + { + if ($value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } + + /** + * Apply the callback if the given "value" is falsy. + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function unless($value, $callback, $default = null) + { + if (! $value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } +} diff --git a/src/Illuminate/Support/Traits/ForwardsCalls.php b/src/Illuminate/Support/Traits/ForwardsCalls.php index bf9a2fc0445fc2c46d89be8a9a166feaf1105a6c..e7181809804f688a57f1c6206a9e0f82b1ab3c9d 100644 --- a/src/Illuminate/Support/Traits/ForwardsCalls.php +++ b/src/Illuminate/Support/Traits/ForwardsCalls.php @@ -21,7 +21,7 @@ trait ForwardsCalls { try { return $object->{$method}(...$parameters); - } catch (Error | BadMethodCallException $e) { + } catch (Error|BadMethodCallException $e) { $pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~'; if (! preg_match($pattern, $e->getMessage(), $matches)) { @@ -37,6 +37,27 @@ trait ForwardsCalls } } + /** + * Forward a method call to the given object, returning $this if the forwarded call returned itself. + * + * @param mixed $object + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \BadMethodCallException + */ + protected function forwardDecoratedCallTo($object, $method, $parameters) + { + $result = $this->forwardCallTo($object, $method, $parameters); + + if ($result === $object) { + return $this; + } + + return $result; + } + /** * Throw a bad method call exception for the given method. * diff --git a/src/Illuminate/Support/Traits/ReflectsClosures.php b/src/Illuminate/Support/Traits/ReflectsClosures.php new file mode 100644 index 0000000000000000000000000000000000000000..bf47d7ec20c6fdefeb9b71db48cca0e1db53f18d --- /dev/null +++ b/src/Illuminate/Support/Traits/ReflectsClosures.php @@ -0,0 +1,88 @@ +<?php + +namespace Illuminate\Support\Traits; + +use Closure; +use Illuminate\Support\Reflector; +use ReflectionFunction; +use RuntimeException; + +trait ReflectsClosures +{ + /** + * Get the class name of the first parameter of the given Closure. + * + * @param \Closure $closure + * @return string + * + * @throws \ReflectionException + * @throws \RuntimeException + */ + protected function firstClosureParameterType(Closure $closure) + { + $types = array_values($this->closureParameterTypes($closure)); + + if (! $types) { + throw new RuntimeException('The given Closure has no parameters.'); + } + + if ($types[0] === null) { + throw new RuntimeException('The first parameter of the given Closure is missing a type hint.'); + } + + return $types[0]; + } + + /** + * Get the class names of the first parameter of the given Closure, including union types. + * + * @param \Closure $closure + * @return array + * + * @throws \ReflectionException + * @throws \RuntimeException + */ + protected function firstClosureParameterTypes(Closure $closure) + { + $reflection = new ReflectionFunction($closure); + + $types = collect($reflection->getParameters())->mapWithKeys(function ($parameter) { + if ($parameter->isVariadic()) { + return [$parameter->getName() => null]; + } + + return [$parameter->getName() => Reflector::getParameterClassNames($parameter)]; + })->filter()->values()->all(); + + if (empty($types)) { + throw new RuntimeException('The given Closure has no parameters.'); + } + + if (isset($types[0]) && empty($types[0])) { + throw new RuntimeException('The first parameter of the given Closure is missing a type hint.'); + } + + return $types[0]; + } + + /** + * Get the class names / types of the parameters of the given Closure. + * + * @param \Closure $closure + * @return array + * + * @throws \ReflectionException + */ + protected function closureParameterTypes(Closure $closure) + { + $reflection = new ReflectionFunction($closure); + + return collect($reflection->getParameters())->mapWithKeys(function ($parameter) { + if ($parameter->isVariadic()) { + return [$parameter->getName() => null]; + } + + return [$parameter->getName() => Reflector::getParameterClassName($parameter)]; + })->all(); + } +} diff --git a/src/Illuminate/Support/Traits/Tappable.php b/src/Illuminate/Support/Traits/Tappable.php index e4a321cdfd006c29f870de4aae801de5b6d12366..9353451ad0cd58f3d4e1939b3e39da909c3ce52a 100644 --- a/src/Illuminate/Support/Traits/Tappable.php +++ b/src/Illuminate/Support/Traits/Tappable.php @@ -8,7 +8,7 @@ trait Tappable * Call the given Closure with this instance then return the instance. * * @param callable|null $callback - * @return mixed + * @return $this|\Illuminate\Support\HigherOrderTapProxy */ public function tap($callback = null) { diff --git a/src/Illuminate/Support/ValidatedInput.php b/src/Illuminate/Support/ValidatedInput.php new file mode 100644 index 0000000000000000000000000000000000000000..07df014f70dc15e5eb734d4bbf3c5ee397ebf356 --- /dev/null +++ b/src/Illuminate/Support/ValidatedInput.php @@ -0,0 +1,219 @@ +<?php + +namespace Illuminate\Support; + +use ArrayIterator; +use Illuminate\Contracts\Support\ValidatedData; +use stdClass; + +class ValidatedInput implements ValidatedData +{ + /** + * The underlying input. + * + * @var array + */ + protected $input; + + /** + * Create a new validated input container. + * + * @param array $input + * @return void + */ + public function __construct(array $input) + { + $this->input = $input; + } + + /** + * Get a subset containing the provided keys with values from the input data. + * + * @param array|mixed $keys + * @return array + */ + public function only($keys) + { + $results = []; + + $input = $this->input; + + $placeholder = new stdClass; + + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + $value = data_get($input, $key, $placeholder); + + if ($value !== $placeholder) { + Arr::set($results, $key, $value); + } + } + + return $results; + } + + /** + * Get all of the input except for a specified array of items. + * + * @param array|mixed $keys + * @return array + */ + public function except($keys) + { + $keys = is_array($keys) ? $keys : func_get_args(); + + $results = $this->input; + + Arr::forget($results, $keys); + + return $results; + } + + /** + * Merge the validated input with the given array of additional data. + * + * @param array $items + * @return static + */ + public function merge(array $items) + { + return new static(array_merge($this->input, $items)); + } + + /** + * Get the input as a collection. + * + * @return \Illuminate\Support\Collection + */ + public function collect() + { + return new Collection($this->input); + } + + /** + * Get the raw, underlying input array. + * + * @return array + */ + public function all() + { + return $this->input; + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return $this->all(); + } + + /** + * Dynamically access input data. + * + * @param string $name + * @return mixed + */ + public function __get($name) + { + return $this->input[$name]; + } + + /** + * Dynamically set input data. + * + * @param string $name + * @param mixed $value + * @return mixed + */ + public function __set($name, $value) + { + $this->input[$name] = $value; + } + + /** + * Determine if an input key is set. + * + * @return bool + */ + public function __isset($name) + { + return isset($this->input[$name]); + } + + /** + * Remove an input key. + * + * @param string $name + * @return void + */ + public function __unset($name) + { + unset($this->input[$name]); + } + + /** + * Determine if an item exists at an offset. + * + * @param mixed $key + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($key) + { + return isset($this->input[$key]); + } + + /** + * Get an item at a given offset. + * + * @param mixed $key + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($key) + { + return $this->input[$key]; + } + + /** + * Set the item at a given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($key, $value) + { + if (is_null($key)) { + $this->input[] = $value; + } else { + $this->input[$key] = $value; + } + } + + /** + * Unset the item at a given offset. + * + * @param string $key + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($key) + { + unset($this->input[$key]); + } + + /** + * Get an iterator for the input. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->input); + } +} diff --git a/src/Illuminate/Support/ViewErrorBag.php b/src/Illuminate/Support/ViewErrorBag.php index 0f273b5b757369f80be5aa6c33b62f3dcfca3c5a..d51bb534d84e9735881c72be6f1f3ceeac04602f 100644 --- a/src/Illuminate/Support/ViewErrorBag.php +++ b/src/Illuminate/Support/ViewErrorBag.php @@ -78,6 +78,7 @@ class ViewErrorBag implements Countable * * @return int */ + #[\ReturnTypeWillChange] public function count() { return $this->getBag('default')->count(); diff --git a/src/Illuminate/Support/composer.json b/src/Illuminate/Support/composer.json index 850963e52f584e769e2c69b32e799753989f789b..527bdcbb86908e07608863aa332b3e5ab1de6812 100644 --- a/src/Illuminate/Support/composer.json +++ b/src/Illuminate/Support/composer.json @@ -14,12 +14,15 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", "ext-mbstring": "*", "doctrine/inflector": "^1.4|^2.0", - "illuminate/contracts": "^6.0", - "nesbot/carbon": "^2.31" + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "nesbot/carbon": "^2.53.1", + "voku/portable-ascii": "^1.6.1" }, "conflict": { "tightenco/collect": "<5.5.33" @@ -34,16 +37,16 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/filesystem": "Required to use the composer class (^6.0).", - "moontoast/math": "Required to use ordered UUIDs (^1.1).", - "ramsey/uuid": "Required to use Str::uuid() (^3.7).", - "symfony/process": "Required to use the composer class (^4.3.4).", - "symfony/var-dumper": "Required to use the dd function (^4.3.4).", - "vlucas/phpdotenv": "Required to use the Env class and env helper (^3.3)." + "illuminate/filesystem": "Required to use the composer class (^8.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^1.3|^2.0.2).", + "ramsey/uuid": "Required to use Str::uuid() (^4.2.2).", + "symfony/process": "Required to use the composer class (^5.4).", + "symfony/var-dumper": "Required to use the dd function (^5.4).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.4.1)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/Support/helpers.php b/src/Illuminate/Support/helpers.php index d968dba5466dd35f699dfe8ef7cf135c4564ab84..0b82fe76939de008c47fb2ddd7d46d7a39d8d2c5 100755 --- a/src/Illuminate/Support/helpers.php +++ b/src/Illuminate/Support/helpers.php @@ -1,8 +1,8 @@ <?php +use Illuminate\Contracts\Support\DeferringDisplayableValue; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; use Illuminate\Support\Env; use Illuminate\Support\HigherOrderTapProxy; use Illuminate\Support\Optional; @@ -97,158 +97,25 @@ if (! function_exists('class_uses_recursive')) { } } -if (! function_exists('collect')) { - /** - * Create a collection from the given value. - * - * @param mixed $value - * @return \Illuminate\Support\Collection - */ - function collect($value = null) - { - return new Collection($value); - } -} - -if (! function_exists('data_fill')) { - /** - * Fill in data where it's missing. - * - * @param mixed $target - * @param string|array $key - * @param mixed $value - * @return mixed - */ - function data_fill(&$target, $key, $value) - { - return data_set($target, $key, $value, false); - } -} - -if (! function_exists('data_get')) { - /** - * Get an item from an array or object using "dot" notation. - * - * @param mixed $target - * @param string|array|int $key - * @param mixed $default - * @return mixed - */ - function data_get($target, $key, $default = null) - { - if (is_null($key)) { - return $target; - } - - $key = is_array($key) ? $key : explode('.', $key); - - while (! is_null($segment = array_shift($key))) { - if ($segment === '*') { - if ($target instanceof Collection) { - $target = $target->all(); - } elseif (! is_array($target)) { - return value($default); - } - - $result = []; - - foreach ($target as $item) { - $result[] = data_get($item, $key); - } - - return in_array('*', $key) ? Arr::collapse($result) : $result; - } - - if (Arr::accessible($target) && Arr::exists($target, $segment)) { - $target = $target[$segment]; - } elseif (is_object($target) && isset($target->{$segment})) { - $target = $target->{$segment}; - } else { - return value($default); - } - } - - return $target; - } -} - -if (! function_exists('data_set')) { - /** - * Set an item on an array or object using dot notation. - * - * @param mixed $target - * @param string|array $key - * @param mixed $value - * @param bool $overwrite - * @return mixed - */ - function data_set(&$target, $key, $value, $overwrite = true) - { - $segments = is_array($key) ? $key : explode('.', $key); - - if (($segment = array_shift($segments)) === '*') { - if (! Arr::accessible($target)) { - $target = []; - } - - if ($segments) { - foreach ($target as &$inner) { - data_set($inner, $segments, $value, $overwrite); - } - } elseif ($overwrite) { - foreach ($target as &$inner) { - $inner = $value; - } - } - } elseif (Arr::accessible($target)) { - if ($segments) { - if (! Arr::exists($target, $segment)) { - $target[$segment] = []; - } - - data_set($target[$segment], $segments, $value, $overwrite); - } elseif ($overwrite || ! Arr::exists($target, $segment)) { - $target[$segment] = $value; - } - } elseif (is_object($target)) { - if ($segments) { - if (! isset($target->{$segment})) { - $target->{$segment} = []; - } - - data_set($target->{$segment}, $segments, $value, $overwrite); - } elseif ($overwrite || ! isset($target->{$segment})) { - $target->{$segment} = $value; - } - } else { - $target = []; - - if ($segments) { - data_set($target[$segment], $segments, $value, $overwrite); - } elseif ($overwrite) { - $target[$segment] = $value; - } - } - - return $target; - } -} - if (! function_exists('e')) { /** * Encode HTML special characters in a string. * - * @param \Illuminate\Contracts\Support\Htmlable|string $value + * @param \Illuminate\Contracts\Support\DeferringDisplayableValue|\Illuminate\Contracts\Support\Htmlable|string|null $value * @param bool $doubleEncode * @return string */ function e($value, $doubleEncode = true) { + if ($value instanceof DeferringDisplayableValue) { + $value = $value->resolveDisplayableValue(); + } + if ($value instanceof Htmlable) { return $value->toHtml(); } - return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', $doubleEncode); + return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8', $doubleEncode); } } @@ -279,44 +146,18 @@ if (! function_exists('filled')) { } } -if (! function_exists('head')) { - /** - * Get the first element of an array. Useful for method chaining. - * - * @param array $array - * @return mixed - */ - function head($array) - { - return reset($array); - } -} - -if (! function_exists('last')) { - /** - * Get the last element from an array. - * - * @param array $array - * @return mixed - */ - function last($array) - { - return end($array); - } -} - if (! function_exists('object_get')) { /** * Get an item from an object using "dot" notation. * * @param object $object - * @param string $key + * @param string|null $key * @param mixed $default * @return mixed */ function object_get($object, $key, $default = null) { - if (is_null($key) || trim($key) == '') { + if (is_null($key) || trim($key) === '') { return $object; } @@ -375,13 +216,13 @@ if (! function_exists('retry')) { * * @param int $times * @param callable $callback - * @param int $sleep - * @param callable $when + * @param int|\Closure $sleepMilliseconds + * @param callable|null $when * @return mixed * * @throws \Exception */ - function retry($times, callable $callback, $sleep = 0, $when = null) + function retry($times, callable $callback, $sleepMilliseconds = 0, $when = null) { $attempts = 0; @@ -396,8 +237,8 @@ if (! function_exists('retry')) { throw $e; } - if ($sleep) { - usleep($sleep * 1000); + if ($sleepMilliseconds) { + usleep(value($sleepMilliseconds, $attempts) * 1000); } goto beginning; @@ -431,15 +272,19 @@ if (! function_exists('throw_if')) { * * @param mixed $condition * @param \Throwable|string $exception - * @param array ...$parameters + * @param mixed ...$parameters * @return mixed * * @throws \Throwable */ - function throw_if($condition, $exception, ...$parameters) + function throw_if($condition, $exception = 'RuntimeException', ...$parameters) { if ($condition) { - throw (is_string($exception) ? new $exception(...$parameters) : $exception); + if (is_string($exception) && class_exists($exception)) { + $exception = new $exception(...$parameters); + } + + throw is_string($exception) ? new RuntimeException($exception) : $exception; } return $condition; @@ -452,16 +297,14 @@ if (! function_exists('throw_unless')) { * * @param mixed $condition * @param \Throwable|string $exception - * @param array ...$parameters + * @param mixed ...$parameters * @return mixed * * @throws \Throwable */ - function throw_unless($condition, $exception, ...$parameters) + function throw_unless($condition, $exception = 'RuntimeException', ...$parameters) { - if (! $condition) { - throw (is_string($exception) ? new $exception(...$parameters) : $exception); - } + throw_if(! $condition, $exception, ...$parameters); return $condition; } @@ -476,7 +319,7 @@ if (! function_exists('trait_uses_recursive')) { */ function trait_uses_recursive($trait) { - $traits = class_uses($trait); + $traits = class_uses($trait) ?: []; foreach ($traits as $trait) { $traits += trait_uses_recursive($trait); @@ -509,19 +352,6 @@ if (! function_exists('transform')) { } } -if (! function_exists('value')) { - /** - * Return the default value of the given value. - * - * @param mixed $value - * @return mixed - */ - function value($value) - { - return $value instanceof Closure ? $value() : $value; - } -} - if (! function_exists('windows_os')) { /** * Determine whether the current environment is Windows based. diff --git a/src/Illuminate/Testing/Assert.php b/src/Illuminate/Testing/Assert.php new file mode 100644 index 0000000000000000000000000000000000000000..c0184b7b663e06071f675860ea868a0ae23793b0 --- /dev/null +++ b/src/Illuminate/Testing/Assert.php @@ -0,0 +1,79 @@ +<?php + +namespace Illuminate\Testing; + +use ArrayAccess; +use Illuminate\Testing\Constraints\ArraySubset; +use PHPUnit\Framework\Assert as PHPUnit; +use PHPUnit\Framework\Constraint\DirectoryExists; +use PHPUnit\Framework\Constraint\FileExists; +use PHPUnit\Framework\Constraint\LogicalNot; +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\InvalidArgumentException; + +/** + * @internal This class is not meant to be used or overwritten outside the framework itself. + */ +abstract class Assert extends PHPUnit +{ + /** + * Asserts that an array has a specified subset. + * + * @param \ArrayAccess|array $subset + * @param \ArrayAccess|array $array + * @param bool $checkForIdentity + * @param string $msg + * @return void + */ + public static function assertArraySubset($subset, $array, bool $checkForIdentity = false, string $msg = ''): void + { + if (! (is_array($subset) || $subset instanceof ArrayAccess)) { + throw InvalidArgumentException::create(1, 'array or ArrayAccess'); + } + + if (! (is_array($array) || $array instanceof ArrayAccess)) { + throw InvalidArgumentException::create(2, 'array or ArrayAccess'); + } + + $constraint = new ArraySubset($subset, $checkForIdentity); + + PHPUnit::assertThat($array, $constraint, $msg); + } + + /** + * Asserts that a file does not exist. + * + * @param string $filename + * @param string $message + * @return void + */ + public static function assertFileDoesNotExist(string $filename, string $message = ''): void + { + static::assertThat($filename, new LogicalNot(new FileExists), $message); + } + + /** + * Asserts that a directory does not exist. + * + * @param string $directory + * @param string $message + * @return void + */ + public static function assertDirectoryDoesNotExist(string $directory, string $message = ''): void + { + static::assertThat($directory, new LogicalNot(new DirectoryExists), $message); + } + + /** + * Asserts that a string matches a given regular expression. + * + * @param string $pattern + * @param string $string + * @param string $message + * @return void + */ + public static function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void + { + static::assertThat($string, new RegularExpression($pattern), $message); + } +} diff --git a/src/Illuminate/Testing/AssertableJsonString.php b/src/Illuminate/Testing/AssertableJsonString.php new file mode 100644 index 0000000000000000000000000000000000000000..1964a7c5ce7ce99c2b3c577d5ec6a52d0a59ffab --- /dev/null +++ b/src/Illuminate/Testing/AssertableJsonString.php @@ -0,0 +1,394 @@ +<?php + +namespace Illuminate\Testing; + +use ArrayAccess; +use Countable; +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Illuminate\Testing\Assert as PHPUnit; +use JsonSerializable; + +class AssertableJsonString implements ArrayAccess, Countable +{ + /** + * The original encoded json. + * + * @var \Illuminate\Contracts\Support\Jsonable|\JsonSerializable|array + */ + public $json; + + /** + * The decoded json contents. + * + * @var array|null + */ + protected $decoded; + + /** + * Create a new assertable JSON string instance. + * + * @param \Illuminate\Contracts\Support\Jsonable|\JsonSerializable|array|string $jsonable + * @return void + */ + public function __construct($jsonable) + { + $this->json = $jsonable; + + if ($jsonable instanceof JsonSerializable) { + $this->decoded = $jsonable->jsonSerialize(); + } elseif ($jsonable instanceof Jsonable) { + $this->decoded = json_decode($jsonable->toJson(), true); + } elseif (is_array($jsonable)) { + $this->decoded = $jsonable; + } else { + $this->decoded = json_decode($jsonable, true); + } + } + + /** + * Validate and return the decoded response JSON. + * + * @param string|null $key + * @return mixed + */ + public function json($key = null) + { + return data_get($this->decoded, $key); + } + + /** + * Assert that the response JSON has the expected count of items at the given key. + * + * @param int $count + * @param string|null $key + * @return $this + */ + public function assertCount(int $count, $key = null) + { + if (! is_null($key)) { + PHPUnit::assertCount( + $count, data_get($this->decoded, $key), + "Failed to assert that the response count matched the expected {$count}" + ); + + return $this; + } + + PHPUnit::assertCount($count, + $this->decoded, + "Failed to assert that the response count matched the expected {$count}" + ); + + return $this; + } + + /** + * Assert that the response has the exact given JSON. + * + * @param array $data + * @return $this + */ + public function assertExact(array $data) + { + $actual = $this->reorderAssocKeys((array) $this->decoded); + + $expected = $this->reorderAssocKeys($data); + + PHPUnit::assertEquals( + json_encode($expected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + return $this; + } + + /** + * Assert that the response has the similar JSON as given. + * + * @param array $data + * @return $this + */ + public function assertSimilar(array $data) + { + $actual = json_encode(Arr::sortRecursive( + (array) $this->decoded + )); + + PHPUnit::assertEquals(json_encode(Arr::sortRecursive($data)), $actual); + + return $this; + } + + /** + * Assert that the response contains the given JSON fragment. + * + * @param array $data + * @return $this + */ + public function assertFragment(array $data) + { + $actual = json_encode(Arr::sortRecursive( + (array) $this->decoded + )); + + foreach (Arr::sortRecursive($data) as $key => $value) { + $expected = $this->jsonSearchStrings($key, $value); + + PHPUnit::assertTrue( + Str::contains($actual, $expected), + 'Unable to find JSON fragment: '.PHP_EOL.PHP_EOL. + '['.json_encode([$key => $value]).']'.PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$actual}]." + ); + } + + return $this; + } + + /** + * Assert that the response does not contain the given JSON fragment. + * + * @param array $data + * @param bool $exact + * @return $this + */ + public function assertMissing(array $data, $exact = false) + { + if ($exact) { + return $this->assertMissingExact($data); + } + + $actual = json_encode(Arr::sortRecursive( + (array) $this->decoded + )); + + foreach (Arr::sortRecursive($data) as $key => $value) { + $unexpected = $this->jsonSearchStrings($key, $value); + + PHPUnit::assertFalse( + Str::contains($actual, $unexpected), + 'Found unexpected JSON fragment: '.PHP_EOL.PHP_EOL. + '['.json_encode([$key => $value]).']'.PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$actual}]." + ); + } + + return $this; + } + + /** + * Assert that the response does not contain the exact JSON fragment. + * + * @param array $data + * @return $this + */ + public function assertMissingExact(array $data) + { + $actual = json_encode(Arr::sortRecursive( + (array) $this->decoded + )); + + foreach (Arr::sortRecursive($data) as $key => $value) { + $unexpected = $this->jsonSearchStrings($key, $value); + + if (! Str::contains($actual, $unexpected)) { + return $this; + } + } + + PHPUnit::fail( + 'Found unexpected JSON fragment: '.PHP_EOL.PHP_EOL. + '['.json_encode($data).']'.PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$actual}]." + ); + + return $this; + } + + /** + * Assert that the expected value and type exists at the given path in the response. + * + * @param string $path + * @param mixed $expect + * @return $this + */ + public function assertPath($path, $expect) + { + PHPUnit::assertSame($expect, $this->json($path)); + + return $this; + } + + /** + * Assert that the response has a given JSON structure. + * + * @param array|null $structure + * @param array|null $responseData + * @return $this + */ + public function assertStructure(array $structure = null, $responseData = null) + { + if (is_null($structure)) { + return $this->assertSimilar($this->decoded); + } + + if (! is_null($responseData)) { + return (new static($responseData))->assertStructure($structure); + } + + foreach ($structure as $key => $value) { + if (is_array($value) && $key === '*') { + PHPUnit::assertIsArray($this->decoded); + + foreach ($this->decoded as $responseDataItem) { + $this->assertStructure($structure['*'], $responseDataItem); + } + } elseif (is_array($value)) { + PHPUnit::assertArrayHasKey($key, $this->decoded); + + $this->assertStructure($structure[$key], $this->decoded[$key]); + } else { + PHPUnit::assertArrayHasKey($value, $this->decoded); + } + } + + return $this; + } + + /** + * Assert that the response is a superset of the given JSON. + * + * @param array $data + * @param bool $strict + * @return $this + */ + public function assertSubset(array $data, $strict = false) + { + PHPUnit::assertArraySubset( + $data, $this->decoded, $strict, $this->assertJsonMessage($data) + ); + + return $this; + } + + /** + * Reorder associative array keys to make it easy to compare arrays. + * + * @param array $data + * @return array + */ + protected function reorderAssocKeys(array $data) + { + $data = Arr::dot($data); + ksort($data); + + $result = []; + + foreach ($data as $key => $value) { + Arr::set($result, $key, $value); + } + + return $result; + } + + /** + * Get the assertion message for assertJson. + * + * @param array $data + * @return string + */ + protected function assertJsonMessage(array $data) + { + $expected = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $actual = json_encode($this->decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return 'Unable to find JSON: '.PHP_EOL.PHP_EOL. + "[{$expected}]".PHP_EOL.PHP_EOL. + 'within response JSON:'.PHP_EOL.PHP_EOL. + "[{$actual}].".PHP_EOL.PHP_EOL; + } + + /** + * Get the strings we need to search for when examining the JSON. + * + * @param string $key + * @param string $value + * @return array + */ + protected function jsonSearchStrings($key, $value) + { + $needle = substr(json_encode([$key => $value]), 1, -1); + + return [ + $needle.']', + $needle.'}', + $needle.',', + ]; + } + + /** + * Get the total number of items in the underlying JSON array. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + return count($this->decoded); + } + + /** + * Determine whether an offset exists. + * + * @param mixed $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->decoded[$offset]); + } + + /** + * Get the value at the given offset. + * + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->decoded[$offset]; + } + + /** + * Set the value at the given offset. + * + * @param string $offset + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + $this->decoded[$offset] = $value; + } + + /** + * Unset the value at the given offset. + * + * @param string $offset + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->decoded[$offset]); + } +} diff --git a/src/Illuminate/Testing/Concerns/TestDatabases.php b/src/Illuminate/Testing/Concerns/TestDatabases.php new file mode 100644 index 0000000000000000000000000000000000000000..c27c2d3da4d21b118d70e0dfbf4a0cb713b4d1f9 --- /dev/null +++ b/src/Illuminate/Testing/Concerns/TestDatabases.php @@ -0,0 +1,180 @@ +<?php + +namespace Illuminate\Testing\Concerns; + +use Illuminate\Database\QueryException; +use Illuminate\Foundation\Testing; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\ParallelTesting; +use Illuminate\Support\Facades\Schema; + +trait TestDatabases +{ + /** + * Indicates if the test database schema is up to date. + * + * @var bool + */ + protected static $schemaIsUpToDate = false; + + /** + * Boot a test database. + * + * @return void + */ + protected function bootTestDatabase() + { + ParallelTesting::setUpProcess(function () { + $this->whenNotUsingInMemoryDatabase(function ($database) { + if (ParallelTesting::option('recreate_databases')) { + Schema::dropDatabaseIfExists( + $this->testDatabase($database) + ); + } + }); + }); + + ParallelTesting::setUpTestCase(function ($testCase) { + $uses = array_flip(class_uses_recursive(get_class($testCase))); + + $databaseTraits = [ + Testing\DatabaseMigrations::class, + Testing\DatabaseTransactions::class, + Testing\RefreshDatabase::class, + ]; + + if (Arr::hasAny($uses, $databaseTraits)) { + if (! ParallelTesting::option('without_databases')) { + $this->whenNotUsingInMemoryDatabase(function ($database) use ($uses) { + [$testDatabase, $created] = $this->ensureTestDatabaseExists($database); + + $this->switchToDatabase($testDatabase); + + if (isset($uses[Testing\DatabaseTransactions::class])) { + $this->ensureSchemaIsUpToDate(); + } + + if ($created) { + ParallelTesting::callSetUpTestDatabaseCallbacks($testDatabase); + } + }); + } + } + }); + } + + /** + * Ensure a test database exists and returns its name. + * + * @param string $database + * @return array + */ + protected function ensureTestDatabaseExists($database) + { + $testDatabase = $this->testDatabase($database); + + try { + $this->usingDatabase($testDatabase, function () { + Schema::hasTable('dummy'); + }); + } catch (QueryException $e) { + $this->usingDatabase($database, function () use ($testDatabase) { + Schema::dropDatabaseIfExists($testDatabase); + Schema::createDatabase($testDatabase); + }); + + return [$testDatabase, true]; + } + + return [$testDatabase, false]; + } + + /** + * Ensure the current database test schema is up to date. + * + * @return void + */ + protected function ensureSchemaIsUpToDate() + { + if (! static::$schemaIsUpToDate) { + Artisan::call('migrate'); + + static::$schemaIsUpToDate = true; + } + } + + /** + * Runs the given callable using the given database. + * + * @param string $database + * @param callable $callable + * @return void + */ + protected function usingDatabase($database, $callable) + { + $original = DB::getConfig('database'); + + try { + $this->switchToDatabase($database); + $callable(); + } finally { + $this->switchToDatabase($original); + } + } + + /** + * Apply the given callback when tests are not using in memory database. + * + * @param callable $callback + * @return void + */ + protected function whenNotUsingInMemoryDatabase($callback) + { + $database = DB::getConfig('database'); + + if ($database !== ':memory:') { + $callback($database); + } + } + + /** + * Switch to the given database. + * + * @param string $database + * @return void + */ + protected function switchToDatabase($database) + { + DB::purge(); + + $default = config('database.default'); + + $url = config("database.connections.{$default}.url"); + + if ($url) { + config()->set( + "database.connections.{$default}.url", + preg_replace('/^(.*)(\/[\w-]*)(\??.*)$/', "$1/{$database}$3", $url), + ); + } else { + config()->set( + "database.connections.{$default}.database", + $database, + ); + } + } + + /** + * Returns the test database name. + * + * @return string + */ + protected function testDatabase($database) + { + $token = ParallelTesting::token(); + + return "{$database}_test_{$token}"; + } +} diff --git a/src/Illuminate/Foundation/Testing/Constraints/ArraySubset.php b/src/Illuminate/Testing/Constraints/ArraySubset.php similarity index 96% rename from src/Illuminate/Foundation/Testing/Constraints/ArraySubset.php rename to src/Illuminate/Testing/Constraints/ArraySubset.php index 9d07f461523a94d05632df779ef4ec4f196d3d29..c455bdd55487c792bd7685779965d855f6936703 100644 --- a/src/Illuminate/Foundation/Testing/Constraints/ArraySubset.php +++ b/src/Illuminate/Testing/Constraints/ArraySubset.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Foundation\Testing\Constraints; +namespace Illuminate\Testing\Constraints; use ArrayObject; use PHPUnit\Framework\Constraint\Constraint; @@ -27,8 +27,8 @@ if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { /** * Create a new array subset constraint instance. * - * @param iterable $subset - * @param bool $strict + * @param iterable $subset + * @param bool $strict * @return void */ public function __construct(iterable $subset, bool $strict = false) @@ -91,9 +91,9 @@ if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { /** * Returns a string representation of the constraint. * - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException - * * @return string + * + * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException */ public function toString(): string { @@ -143,7 +143,7 @@ if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { return (array) $other; } } -} elseif (class_exists(Version::class) && (int) Version::series()[0] >= 8) { +} else { /** * @internal This class is not meant to be used or overwritten outside the framework itself. */ @@ -162,8 +162,8 @@ if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { /** * Create a new array subset constraint instance. * - * @param iterable $subset - * @param bool $strict + * @param iterable $subset + * @param bool $strict * @return void */ public function __construct(iterable $subset, bool $strict = false) @@ -224,9 +224,9 @@ if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { /** * Returns a string representation of the constraint. * - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException - * * @return string + * + * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException */ public function toString(): string { diff --git a/src/Illuminate/Testing/Constraints/CountInDatabase.php b/src/Illuminate/Testing/Constraints/CountInDatabase.php new file mode 100644 index 0000000000000000000000000000000000000000..3ed6826929a4b3e065af3c84b03171b5e91f2680 --- /dev/null +++ b/src/Illuminate/Testing/Constraints/CountInDatabase.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Testing\Constraints; + +use Illuminate\Database\Connection; +use PHPUnit\Framework\Constraint\Constraint; +use ReflectionClass; + +class CountInDatabase extends Constraint +{ + /** + * The database connection. + * + * @var \Illuminate\Database\Connection + */ + protected $database; + + /** + * The expected table entries count that will be checked against the actual count. + * + * @var int + */ + protected $expectedCount; + + /** + * The actual table entries count that will be checked against the expected count. + * + * @var int + */ + protected $actualCount; + + /** + * Create a new constraint instance. + * + * @param \Illuminate\Database\Connection $database + * @param int $expectedCount + * @return void + */ + public function __construct(Connection $database, int $expectedCount) + { + $this->expectedCount = $expectedCount; + + $this->database = $database; + } + + /** + * Check if the expected and actual count are equal. + * + * @param string $table + * @return bool + */ + public function matches($table): bool + { + $this->actualCount = $this->database->table($table)->count(); + + return $this->actualCount === $this->expectedCount; + } + + /** + * Get the description of the failure. + * + * @param string $table + * @return string + */ + public function failureDescription($table): string + { + return sprintf( + "table [%s] matches expected entries count of %s. Entries found: %s.\n", + $table, $this->expectedCount, $this->actualCount + ); + } + + /** + * Get a string representation of the object. + * + * @param int $options + * @return string + */ + public function toString($options = 0): string + { + return (new ReflectionClass($this))->name; + } +} diff --git a/src/Illuminate/Foundation/Testing/Constraints/HasInDatabase.php b/src/Illuminate/Testing/Constraints/HasInDatabase.php similarity index 90% rename from src/Illuminate/Foundation/Testing/Constraints/HasInDatabase.php rename to src/Illuminate/Testing/Constraints/HasInDatabase.php index c523a003930310b3b8ee26fc30a21fc683c9a27d..039ee4425d9963f303d199157ee718e8890c0fa9 100644 --- a/src/Illuminate/Foundation/Testing/Constraints/HasInDatabase.php +++ b/src/Illuminate/Testing/Constraints/HasInDatabase.php @@ -1,8 +1,9 @@ <?php -namespace Illuminate\Foundation\Testing\Constraints; +namespace Illuminate\Testing\Constraints; use Illuminate\Database\Connection; +use Illuminate\Database\Query\Expression; use PHPUnit\Framework\Constraint\Constraint; class HasInDatabase extends Constraint @@ -111,6 +112,10 @@ class HasInDatabase extends Constraint */ public function toString($options = 0): string { - return json_encode($this->data, $options); + foreach ($this->data as $key => $data) { + $output[$key] = $data instanceof Expression ? (string) $data : $data; + } + + return json_encode($output ?? [], $options); } } diff --git a/src/Illuminate/Testing/Constraints/NotSoftDeletedInDatabase.php b/src/Illuminate/Testing/Constraints/NotSoftDeletedInDatabase.php new file mode 100644 index 0000000000000000000000000000000000000000..ff8195829f9f0beaa97f9abbf676ba9eecdc9f0d --- /dev/null +++ b/src/Illuminate/Testing/Constraints/NotSoftDeletedInDatabase.php @@ -0,0 +1,115 @@ +<?php + +namespace Illuminate\Testing\Constraints; + +use Illuminate\Database\Connection; +use PHPUnit\Framework\Constraint\Constraint; + +class NotSoftDeletedInDatabase extends Constraint +{ + /** + * Number of records that will be shown in the console in case of failure. + * + * @var int + */ + protected $show = 3; + + /** + * The database connection. + * + * @var \Illuminate\Database\Connection + */ + protected $database; + + /** + * The data that will be used to narrow the search in the database table. + * + * @var array + */ + protected $data; + + /** + * The name of the column that indicates soft deletion has occurred. + * + * @var string + */ + protected $deletedAtColumn; + + /** + * Create a new constraint instance. + * + * @param \Illuminate\Database\Connection $database + * @param array $data + * @param string $deletedAtColumn + * @return void + */ + public function __construct(Connection $database, array $data, string $deletedAtColumn) + { + $this->database = $database; + $this->data = $data; + $this->deletedAtColumn = $deletedAtColumn; + } + + /** + * Check if the data is found in the given table. + * + * @param string $table + * @return bool + */ + public function matches($table): bool + { + return $this->database->table($table) + ->where($this->data) + ->whereNull($this->deletedAtColumn) + ->count() > 0; + } + + /** + * Get the description of the failure. + * + * @param string $table + * @return string + */ + public function failureDescription($table): string + { + return sprintf( + "any existing row in the table [%s] matches the attributes %s.\n\n%s", + $table, $this->toString(), $this->getAdditionalInfo($table) + ); + } + + /** + * Get additional info about the records found in the database table. + * + * @param string $table + * @return string + */ + protected function getAdditionalInfo($table) + { + $query = $this->database->table($table); + + $results = $query->limit($this->show)->get(); + + if ($results->isEmpty()) { + return 'The table is empty'; + } + + $description = 'Found: '.json_encode($results, JSON_PRETTY_PRINT); + + if ($query->count() > $this->show) { + $description .= sprintf(' and %s others', $query->count() - $this->show); + } + + return $description; + } + + /** + * Get a string representation of the object. + * + * @return string + */ + public function toString(): string + { + return json_encode($this->data); + } +} diff --git a/src/Illuminate/Foundation/Testing/Constraints/SeeInOrder.php b/src/Illuminate/Testing/Constraints/SeeInOrder.php similarity index 97% rename from src/Illuminate/Foundation/Testing/Constraints/SeeInOrder.php rename to src/Illuminate/Testing/Constraints/SeeInOrder.php index 809eb59b537e110f13fb4bb5d251a4cdcc0ca10b..609f32d50b92b80be3a82a71cc507c385ffae7ef 100644 --- a/src/Illuminate/Foundation/Testing/Constraints/SeeInOrder.php +++ b/src/Illuminate/Testing/Constraints/SeeInOrder.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Foundation\Testing\Constraints; +namespace Illuminate\Testing\Constraints; use PHPUnit\Framework\Constraint\Constraint; use ReflectionClass; diff --git a/src/Illuminate/Foundation/Testing/Constraints/SoftDeletedInDatabase.php b/src/Illuminate/Testing/Constraints/SoftDeletedInDatabase.php similarity index 98% rename from src/Illuminate/Foundation/Testing/Constraints/SoftDeletedInDatabase.php rename to src/Illuminate/Testing/Constraints/SoftDeletedInDatabase.php index f99b9311fb9db039ba5d6cc3d184345f76b92489..baaeee27a1815f9a8017c5f09370374d2fa5d68a 100644 --- a/src/Illuminate/Foundation/Testing/Constraints/SoftDeletedInDatabase.php +++ b/src/Illuminate/Testing/Constraints/SoftDeletedInDatabase.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Foundation\Testing\Constraints; +namespace Illuminate\Testing\Constraints; use Illuminate\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; diff --git a/src/Illuminate/Testing/Fluent/AssertableJson.php b/src/Illuminate/Testing/Fluent/AssertableJson.php new file mode 100644 index 0000000000000000000000000000000000000000..d548e28247f49e47d9615faff735466dc1fb4d1f --- /dev/null +++ b/src/Illuminate/Testing/Fluent/AssertableJson.php @@ -0,0 +1,177 @@ +<?php + +namespace Illuminate\Testing\Fluent; + +use Closure; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Arr; +use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\Tappable; +use Illuminate\Testing\AssertableJsonString; +use PHPUnit\Framework\Assert as PHPUnit; + +class AssertableJson implements Arrayable +{ + use Concerns\Has, + Concerns\Matching, + Concerns\Debugging, + Concerns\Interaction, + Macroable, + Tappable; + + /** + * The properties in the current scope. + * + * @var array + */ + private $props; + + /** + * The "dot" path to the current scope. + * + * @var string|null + */ + private $path; + + /** + * Create a new fluent, assertable JSON data instance. + * + * @param array $props + * @param string|null $path + * @return void + */ + protected function __construct(array $props, string $path = null) + { + $this->path = $path; + $this->props = $props; + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + protected function dotPath(string $key = ''): string + { + if (is_null($this->path)) { + return $key; + } + + return rtrim(implode('.', [$this->path, $key]), '.'); + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + protected function prop(string $key = null) + { + return Arr::get($this->props, $key); + } + + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param \Closure $callback + * @return $this + */ + protected function scope(string $key, Closure $callback): self + { + $props = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertIsArray($props, sprintf('Property [%s] is not scopeable.', $path)); + + $scope = new static($props, $path); + $callback($scope); + $scope->interacted(); + + return $this; + } + + /** + * Instantiate a new "scope" on the first child element. + * + * @param \Closure $callback + * @return $this + */ + public function first(Closure $callback): self + { + $props = $this->prop(); + + $path = $this->dotPath(); + + PHPUnit::assertNotEmpty($props, $path === '' + ? 'Cannot scope directly onto the first element of the root level because it is empty.' + : sprintf('Cannot scope directly onto the first element of property [%s] because it is empty.', $path) + ); + + $key = array_keys($props)[0]; + + $this->interactsWith($key); + + return $this->scope($key, $callback); + } + + /** + * Instantiate a new "scope" on each child element. + * + * @param \Closure $callback + * @return $this + */ + public function each(Closure $callback): self + { + $props = $this->prop(); + + $path = $this->dotPath(); + + PHPUnit::assertNotEmpty($props, $path === '' + ? 'Cannot scope directly onto each element of the root level because it is empty.' + : sprintf('Cannot scope directly onto each element of property [%s] because it is empty.', $path) + ); + + foreach (array_keys($props) as $key) { + $this->interactsWith($key); + + $this->scope($key, $callback); + } + + return $this; + } + + /** + * Create a new instance from an array. + * + * @param array $data + * @return static + */ + public static function fromArray(array $data): self + { + return new static($data); + } + + /** + * Create a new instance from a AssertableJsonString. + * + * @param \Illuminate\Testing\AssertableJsonString $json + * @return static + */ + public static function fromAssertableJsonString(AssertableJsonString $json): self + { + return static::fromArray($json->json()); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return $this->props; + } +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Debugging.php b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php new file mode 100644 index 0000000000000000000000000000000000000000..f51d119074aeab8cf10f6e501a41d2c7a89f0c34 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php @@ -0,0 +1,38 @@ +<?php + +namespace Illuminate\Testing\Fluent\Concerns; + +trait Debugging +{ + /** + * Dumps the given props. + * + * @param string|null $prop + * @return $this + */ + public function dump(string $prop = null): self + { + dump($this->prop($prop)); + + return $this; + } + + /** + * Dumps the given props and exits. + * + * @param string|null $prop + * @return void + */ + public function dd(string $prop = null): void + { + dd($this->prop($prop)); + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php new file mode 100644 index 0000000000000000000000000000000000000000..7765f4a061a51772a165294de3b876b627d8a9cb --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -0,0 +1,213 @@ +<?php + +namespace Illuminate\Testing\Fluent\Concerns; + +use Closure; +use Illuminate\Support\Arr; +use PHPUnit\Framework\Assert as PHPUnit; + +trait Has +{ + /** + * Assert that the prop is of the expected size. + * + * @param string|int $key + * @param int|null $length + * @return $this + */ + public function count($key, int $length = null): self + { + if (is_null($length)) { + $path = $this->dotPath(); + + PHPUnit::assertCount( + $key, + $this->prop(), + $path + ? sprintf('Property [%s] does not have the expected size.', $path) + : sprintf('Root level does not have the expected size.') + ); + + return $this; + } + + PHPUnit::assertCount( + $length, + $this->prop($key), + sprintf('Property [%s] does not have the expected size.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Ensure that the given prop exists. + * + * @param string|int $key + * @param int|\Closure|null $length + * @param \Closure|null $callback + * @return $this + */ + public function has($key, $length = null, Closure $callback = null): self + { + $prop = $this->prop(); + + if (is_int($key) && is_null($length)) { + return $this->count($key); + } + + PHPUnit::assertTrue( + Arr::has($prop, $key), + sprintf('Property [%s] does not exist.', $this->dotPath($key)) + ); + + $this->interactsWith($key); + + if (! is_null($callback)) { + return $this->has($key, function (self $scope) use ($length, $callback) { + return $scope + ->tap(function (self $scope) use ($length) { + if (! is_null($length)) { + $scope->count($length); + } + }) + ->first($callback) + ->etc(); + }); + } + + if (is_callable($length)) { + return $this->scope($key, $length); + } + + if (! is_null($length)) { + return $this->count($key, $length); + } + + return $this; + } + + /** + * Assert that all of the given props exist. + * + * @param array|string $key + * @return $this + */ + public function hasAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop => $count) { + if (is_int($prop)) { + $this->has($count); + } else { + $this->has($prop, $count); + } + } + + return $this; + } + + /** + * Assert that at least one of the given props exists. + * + * @param array|string $key + * @return $this + */ + public function hasAny($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + PHPUnit::assertTrue( + Arr::hasAny($this->prop(), $keys), + sprintf('None of properties [%s] exist.', implode(', ', $keys)) + ); + + foreach ($keys as $key) { + $this->interactsWith($key); + } + + return $this; + } + + /** + * Assert that none of the given props exist. + * + * @param array|string $key + * @return $this + */ + public function missingAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop) { + $this->missing($prop); + } + + return $this; + } + + /** + * Assert that the given prop does not exist. + * + * @param string $key + * @return $this + */ + public function missing(string $key): self + { + PHPUnit::assertNotTrue( + Arr::has($this->prop(), $key), + sprintf('Property [%s] was found while it was expected to be missing.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key = ''): string; + + /** + * Marks the property as interacted. + * + * @param string $key + * @return void + */ + abstract protected function interactsWith(string $key): void; + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); + + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param \Closure $callback + * @return $this + */ + abstract protected function scope(string $key, Closure $callback); + + /** + * Disables the interaction check. + * + * @return $this + */ + abstract public function etc(); + + /** + * Instantiate a new "scope" on the first element. + * + * @param \Closure $callback + * @return $this + */ + abstract public function first(Closure $callback); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Interaction.php b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php new file mode 100644 index 0000000000000000000000000000000000000000..15e7e9508f552803fc9e85a2f3a788df5f59fd85 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php @@ -0,0 +1,67 @@ +<?php + +namespace Illuminate\Testing\Fluent\Concerns; + +use Illuminate\Support\Str; +use PHPUnit\Framework\Assert as PHPUnit; + +trait Interaction +{ + /** + * The list of interacted properties. + * + * @var array + */ + protected $interacted = []; + + /** + * Marks the property as interacted. + * + * @param string $key + * @return void + */ + protected function interactsWith(string $key): void + { + $prop = Str::before($key, '.'); + + if (! in_array($prop, $this->interacted, true)) { + $this->interacted[] = $prop; + } + } + + /** + * Asserts that all properties have been interacted with. + * + * @return void + */ + public function interacted(): void + { + PHPUnit::assertSame( + [], + array_diff(array_keys($this->prop()), $this->interacted), + $this->path + ? sprintf('Unexpected properties were found in scope [%s].', $this->path) + : 'Unexpected properties were found on the root level.' + ); + } + + /** + * Disables the interaction check. + * + * @return $this + */ + public function etc(): self + { + $this->interacted = array_keys($this->prop()); + + return $this; + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php new file mode 100644 index 0000000000000000000000000000000000000000..949047b82bad74bdb6de6274796654ad8624b6dc --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -0,0 +1,193 @@ +<?php + +namespace Illuminate\Testing\Fluent\Concerns; + +use Closure; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Collection; +use PHPUnit\Framework\Assert as PHPUnit; + +trait Matching +{ + /** + * Asserts that the property matches the expected value. + * + * @param string $key + * @param mixed|\Closure $expected + * @return $this + */ + public function where(string $key, $expected): self + { + $this->has($key); + + $actual = $this->prop($key); + + if ($expected instanceof Closure) { + PHPUnit::assertTrue( + $expected(is_array($actual) ? Collection::make($actual) : $actual), + sprintf('Property [%s] was marked as invalid using a closure.', $this->dotPath($key)) + ); + + return $this; + } + + if ($expected instanceof Arrayable) { + $expected = $expected->toArray(); + } + + $this->ensureSorted($expected); + $this->ensureSorted($actual); + + PHPUnit::assertSame( + $expected, + $actual, + sprintf('Property [%s] does not match the expected value.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Asserts that all properties match their expected values. + * + * @param array $bindings + * @return $this + */ + public function whereAll(array $bindings): self + { + foreach ($bindings as $key => $value) { + $this->where($key, $value); + } + + return $this; + } + + /** + * Asserts that the property is of the expected type. + * + * @param string $key + * @param string|array $expected + * @return $this + */ + public function whereType(string $key, $expected): self + { + $this->has($key); + + $actual = $this->prop($key); + + if (! is_array($expected)) { + $expected = explode('|', $expected); + } + + PHPUnit::assertContains( + strtolower(gettype($actual)), + $expected, + sprintf('Property [%s] is not of expected type [%s].', $this->dotPath($key), implode('|', $expected)) + ); + + return $this; + } + + /** + * Asserts that all properties are of their expected types. + * + * @param array $bindings + * @return $this + */ + public function whereAllType(array $bindings): self + { + foreach ($bindings as $key => $value) { + $this->whereType($key, $value); + } + + return $this; + } + + /** + * Asserts that the property contains the expected values. + * + * @param string $key + * @param mixed $expected + * @return $this + */ + public function whereContains(string $key, $expected) + { + $actual = Collection::make( + $this->prop($key) ?? $this->prop() + ); + + $missing = Collection::make($expected)->reject(function ($search) use ($key, $actual) { + if ($actual->containsStrict($key, $search)) { + return true; + } + + return $actual->containsStrict($search); + }); + + if ($missing->whereInstanceOf('Closure')->isNotEmpty()) { + PHPUnit::assertEmpty( + $missing->toArray(), + sprintf( + 'Property [%s] does not contain a value that passes the truth test within the given closure.', + $key, + ) + ); + } else { + PHPUnit::assertEmpty( + $missing->toArray(), + sprintf( + 'Property [%s] does not contain [%s].', + $key, + implode(', ', array_values($missing->toArray())) + ) + ); + } + + return $this; + } + + /** + * Ensures that all properties are sorted the same way, recursively. + * + * @param mixed $value + * @return void + */ + protected function ensureSorted(&$value): void + { + if (! is_array($value)) { + return; + } + + foreach ($value as &$arg) { + $this->ensureSorted($arg); + } + + ksort($value); + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key = ''): string; + + /** + * Ensure that the given prop exists. + * + * @param string $key + * @param null $value + * @param \Closure|null $scope + * @return $this + */ + abstract public function has(string $key, $value = null, Closure $scope = null); + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/LICENSE.md b/src/Illuminate/Testing/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..79810c848f8bdbd8f1629f46079ad482f33fc371 --- /dev/null +++ b/src/Illuminate/Testing/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Illuminate/Testing/LoggedExceptionCollection.php b/src/Illuminate/Testing/LoggedExceptionCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..907b061a6de1d662990414fab77a57f334371ab0 --- /dev/null +++ b/src/Illuminate/Testing/LoggedExceptionCollection.php @@ -0,0 +1,10 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Support\Collection; + +class LoggedExceptionCollection extends Collection +{ + // +} diff --git a/src/Illuminate/Testing/ParallelConsoleOutput.php b/src/Illuminate/Testing/ParallelConsoleOutput.php new file mode 100644 index 0000000000000000000000000000000000000000..91008dde890ddad4877a6d52c9eab80e18568718 --- /dev/null +++ b/src/Illuminate/Testing/ParallelConsoleOutput.php @@ -0,0 +1,60 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Support\Str; +use Symfony\Component\Console\Output\ConsoleOutput; + +class ParallelConsoleOutput extends ConsoleOutput +{ + /** + * The original output instance. + * + * @var \Symfony\Component\Console\Output\OutputInterface + */ + protected $output; + + /** + * The output that should be ignored. + * + * @var array + */ + protected $ignore = [ + 'Running phpunit in', + 'Configuration read from', + ]; + + /** + * Create a new Parallel ConsoleOutput instance. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + public function __construct($output) + { + parent::__construct( + $output->getVerbosity(), + $output->isDecorated(), + $output->getFormatter(), + ); + + $this->output = $output; + } + + /** + * Writes a message to the output. + * + * @param string|iterable $messages + * @param bool $newline + * @param int $options + * @return void + */ + public function write($messages, bool $newline = false, int $options = 0) + { + $messages = collect($messages)->filter(function ($message) { + return ! Str::contains($message, $this->ignore); + }); + + $this->output->write($messages->toArray(), $newline, $options); + } +} diff --git a/src/Illuminate/Testing/ParallelRunner.php b/src/Illuminate/Testing/ParallelRunner.php new file mode 100644 index 0000000000000000000000000000000000000000..fd3b7dc7b5cad681e06cb10c6e199bdeda20551f --- /dev/null +++ b/src/Illuminate/Testing/ParallelRunner.php @@ -0,0 +1,177 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Support\Facades\ParallelTesting; +use ParaTest\Runners\PHPUnit\Options; +use ParaTest\Runners\PHPUnit\RunnerInterface; +use ParaTest\Runners\PHPUnit\WrapperRunner; +use PHPUnit\TextUI\XmlConfiguration\PhpHandler; +use RuntimeException; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; + +class ParallelRunner implements RunnerInterface +{ + /** + * The application resolver callback. + * + * @var \Closure|null + */ + protected static $applicationResolver; + + /** + * The runner resolver callback. + * + * @var \Closure|null + */ + protected static $runnerResolver; + + /** + * The original test runner options. + * + * @var \ParaTest\Runners\PHPUnit\Options + */ + protected $options; + + /** + * The output instance. + * + * @var \Symfony\Component\Console\Output\OutputInterface + */ + protected $output; + + /** + * The original test runner. + * + * @var \ParaTest\Runners\PHPUnit\RunnerInterface + */ + protected $runner; + + /** + * Creates a new test runner instance. + * + * @param \ParaTest\Runners\PHPUnit\Options $options + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + public function __construct(Options $options, OutputInterface $output) + { + $this->options = $options; + + if ($output instanceof ConsoleOutput) { + $output = new ParallelConsoleOutput($output); + } + + $runnerResolver = static::$runnerResolver ?: function (Options $options, OutputInterface $output) { + return new WrapperRunner($options, $output); + }; + + $this->runner = call_user_func($runnerResolver, $options, $output); + } + + /** + * Set the application resolver callback. + * + * @param \Closure|null $resolver + * @return void + */ + public static function resolveApplicationUsing($resolver) + { + static::$applicationResolver = $resolver; + } + + /** + * Set the runner resolver callback. + * + * @param \Closure|null $resolver + * @return void + */ + public static function resolveRunnerUsing($resolver) + { + static::$runnerResolver = $resolver; + } + + /** + * Runs the test suite. + * + * @return void + */ + public function run(): void + { + (new PhpHandler)->handle($this->options->configuration()->php()); + + $this->forEachProcess(function () { + ParallelTesting::callSetUpProcessCallbacks(); + }); + + try { + $this->runner->run(); + } finally { + $this->forEachProcess(function () { + ParallelTesting::callTearDownProcessCallbacks(); + }); + } + } + + /** + * Returns the highest exit code encountered throughout the course of test execution. + * + * @return int + */ + public function getExitCode(): int + { + return $this->runner->getExitCode(); + } + + /** + * Apply the given callback for each process. + * + * @param callable $callback + * @return void + */ + protected function forEachProcess($callback) + { + collect(range(1, $this->options->processes()))->each(function ($token) use ($callback) { + tap($this->createApplication(), function ($app) use ($callback, $token) { + ParallelTesting::resolveTokenUsing(function () use ($token) { + return $token; + }); + + $callback($app); + })->flush(); + }); + } + + /** + * Creates the application. + * + * @return \Illuminate\Contracts\Foundation\Application + * + * @throws \RuntimeException + */ + protected function createApplication() + { + $applicationResolver = static::$applicationResolver ?: function () { + if (trait_exists(\Tests\CreatesApplication::class)) { + $applicationCreator = new class + { + use \Tests\CreatesApplication; + }; + + return $applicationCreator->createApplication(); + } elseif (file_exists(getcwd().'/bootstrap/app.php')) { + $app = require getcwd().'/bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + throw new RuntimeException('Parallel Runner unable to resolve application.'); + }; + + return call_user_func($applicationResolver); + } +} diff --git a/src/Illuminate/Testing/ParallelTesting.php b/src/Illuminate/Testing/ParallelTesting.php new file mode 100644 index 0000000000000000000000000000000000000000..f8bf993af8a12863aaa8622f5c3f29b9940640c2 --- /dev/null +++ b/src/Illuminate/Testing/ParallelTesting.php @@ -0,0 +1,291 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Contracts\Container\Container; +use Illuminate\Support\Str; + +class ParallelTesting +{ + /** + * The container instance. + * + * @var \Illuminate\Contracts\Container\Container + */ + protected $container; + + /** + * The options resolver callback. + * + * @var \Closure|null + */ + protected $optionsResolver; + + /** + * The token resolver callback. + * + * @var \Closure|null + */ + protected $tokenResolver; + + /** + * All of the registered "setUp" process callbacks. + * + * @var array + */ + protected $setUpProcessCallbacks = []; + + /** + * All of the registered "setUp" test case callbacks. + * + * @var array + */ + protected $setUpTestCaseCallbacks = []; + + /** + * All of the registered "setUp" test database callbacks. + * + * @var array + */ + protected $setUpTestDatabaseCallbacks = []; + + /** + * All of the registered "tearDown" process callbacks. + * + * @var array + */ + protected $tearDownProcessCallbacks = []; + + /** + * All of the registered "tearDown" test case callbacks. + * + * @var array + */ + protected $tearDownTestCaseCallbacks = []; + + /** + * Create a new parallel testing instance. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return void + */ + public function __construct(Container $container) + { + $this->container = $container; + } + + /** + * Set a callback that should be used when resolving options. + * + * @param \Closure|null $callback + * @return void + */ + public function resolveOptionsUsing($resolver) + { + $this->optionsResolver = $resolver; + } + + /** + * Set a callback that should be used when resolving the unique process token. + * + * @param \Closure|null $callback + * @return void + */ + public function resolveTokenUsing($resolver) + { + $this->tokenResolver = $resolver; + } + + /** + * Register a "setUp" process callback. + * + * @param callable $callback + * @return void + */ + public function setUpProcess($callback) + { + $this->setUpProcessCallbacks[] = $callback; + } + + /** + * Register a "setUp" test case callback. + * + * @param callable $callback + * @return void + */ + public function setUpTestCase($callback) + { + $this->setUpTestCaseCallbacks[] = $callback; + } + + /** + * Register a "setUp" test database callback. + * + * @param callable $callback + * @return void + */ + public function setUpTestDatabase($callback) + { + $this->setUpTestDatabaseCallbacks[] = $callback; + } + + /** + * Register a "tearDown" process callback. + * + * @param callable $callback + * @return void + */ + public function tearDownProcess($callback) + { + $this->tearDownProcessCallbacks[] = $callback; + } + + /** + * Register a "tearDown" test case callback. + * + * @param callable $callback + * @return void + */ + public function tearDownTestCase($callback) + { + $this->tearDownTestCaseCallbacks[] = $callback; + } + + /** + * Call all of the "setUp" process callbacks. + * + * @return void + */ + public function callSetUpProcessCallbacks() + { + $this->whenRunningInParallel(function () { + foreach ($this->setUpProcessCallbacks as $callback) { + $this->container->call($callback, [ + 'token' => $this->token(), + ]); + } + }); + } + + /** + * Call all of the "setUp" test case callbacks. + * + * @param \Illuminate\Foundation\Testing\TestCase $testCase + * @return void + */ + public function callSetUpTestCaseCallbacks($testCase) + { + $this->whenRunningInParallel(function () use ($testCase) { + foreach ($this->setUpTestCaseCallbacks as $callback) { + $this->container->call($callback, [ + 'testCase' => $testCase, + 'token' => $this->token(), + ]); + } + }); + } + + /** + * Call all of the "setUp" test database callbacks. + * + * @param string $database + * @return void + */ + public function callSetUpTestDatabaseCallbacks($database) + { + $this->whenRunningInParallel(function () use ($database) { + foreach ($this->setUpTestDatabaseCallbacks as $callback) { + $this->container->call($callback, [ + 'database' => $database, + 'token' => $this->token(), + ]); + } + }); + } + + /** + * Call all of the "tearDown" process callbacks. + * + * @return void + */ + public function callTearDownProcessCallbacks() + { + $this->whenRunningInParallel(function () { + foreach ($this->tearDownProcessCallbacks as $callback) { + $this->container->call($callback, [ + 'token' => $this->token(), + ]); + } + }); + } + + /** + * Call all of the "tearDown" test case callbacks. + * + * @param \Illuminate\Foundation\Testing\TestCase $testCase + * @return void + */ + public function callTearDownTestCaseCallbacks($testCase) + { + $this->whenRunningInParallel(function () use ($testCase) { + foreach ($this->tearDownTestCaseCallbacks as $callback) { + $this->container->call($callback, [ + 'testCase' => $testCase, + 'token' => $this->token(), + ]); + } + }); + } + + /** + * Get a parallel testing option. + * + * @param string $option + * @return mixed + */ + public function option($option) + { + $optionsResolver = $this->optionsResolver ?: function ($option) { + $option = 'LARAVEL_PARALLEL_TESTING_'.Str::upper($option); + + return $_SERVER[$option] ?? false; + }; + + return call_user_func($optionsResolver, $option); + } + + /** + * Gets a unique test token. + * + * @return string|false + */ + public function token() + { + return $token = $this->tokenResolver + ? call_user_func($this->tokenResolver) + : ($_SERVER['TEST_TOKEN'] ?? false); + } + + /** + * Apply the callback if tests are running in parallel. + * + * @param callable $callback + * @return void + */ + protected function whenRunningInParallel($callback) + { + if ($this->inParallel()) { + $callback(); + } + } + + /** + * Indicates if the current tests are been run in parallel. + * + * @return bool + */ + protected function inParallel() + { + return ! empty($_SERVER['LARAVEL_PARALLEL_TESTING']) && $this->token(); + } +} diff --git a/src/Illuminate/Testing/ParallelTestingServiceProvider.php b/src/Illuminate/Testing/ParallelTestingServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..20b900d2e58e3a5fbbfde0c29aed099bf11c3789 --- /dev/null +++ b/src/Illuminate/Testing/ParallelTestingServiceProvider.php @@ -0,0 +1,38 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Support\ServiceProvider; +use Illuminate\Testing\Concerns\TestDatabases; + +class ParallelTestingServiceProvider extends ServiceProvider implements DeferrableProvider +{ + use TestDatabases; + + /** + * Boot the application's service providers. + * + * @return void + */ + public function boot() + { + if ($this->app->runningInConsole()) { + $this->bootTestDatabase(); + } + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + if ($this->app->runningInConsole()) { + $this->app->singleton(ParallelTesting::class, function () { + return new ParallelTesting($this->app); + }); + } + } +} diff --git a/src/Illuminate/Testing/PendingCommand.php b/src/Illuminate/Testing/PendingCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..8701086443369c3aa79a1792203436d5f91159ff --- /dev/null +++ b/src/Illuminate/Testing/PendingCommand.php @@ -0,0 +1,415 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Console\OutputStyle; +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Arr; +use Mockery; +use Mockery\Exception\NoMatchingExpectationException; +use PHPUnit\Framework\TestCase as PHPUnitTestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class PendingCommand +{ + /** + * The test being run. + * + * @var \Illuminate\Foundation\Testing\TestCase + */ + public $test; + + /** + * The application instance. + * + * @var \Illuminate\Contracts\Container\Container + */ + protected $app; + + /** + * The command to run. + * + * @var string + */ + protected $command; + + /** + * The parameters to pass to the command. + * + * @var array + */ + protected $parameters; + + /** + * The expected exit code. + * + * @var int + */ + protected $expectedExitCode; + + /** + * The unexpected exit code. + * + * @var int + */ + protected $unexpectedExitCode; + + /** + * Determine if the command has executed. + * + * @var bool + */ + protected $hasExecuted = false; + + /** + * Create a new pending console command run. + * + * @param \PHPUnit\Framework\TestCase $test + * @param \Illuminate\Contracts\Container\Container $app + * @param string $command + * @param array $parameters + * @return void + */ + public function __construct(PHPUnitTestCase $test, Container $app, $command, $parameters) + { + $this->app = $app; + $this->test = $test; + $this->command = $command; + $this->parameters = $parameters; + } + + /** + * Specify an expected question that will be asked when the command runs. + * + * @param string $question + * @param string|bool $answer + * @return $this + */ + public function expectsQuestion($question, $answer) + { + $this->test->expectedQuestions[] = [$question, $answer]; + + return $this; + } + + /** + * Specify an expected confirmation question that will be asked when the command runs. + * + * @param string $question + * @param string $answer + * @return $this + */ + public function expectsConfirmation($question, $answer = 'no') + { + return $this->expectsQuestion($question, strtolower($answer) === 'yes'); + } + + /** + * Specify an expected choice question with expected answers that will be asked/shown when the command runs. + * + * @param string $question + * @param string|array $answer + * @param array $answers + * @param bool $strict + * @return $this + */ + public function expectsChoice($question, $answer, $answers, $strict = false) + { + $this->test->expectedChoices[$question] = [ + 'expected' => $answers, + 'strict' => $strict, + ]; + + return $this->expectsQuestion($question, $answer); + } + + /** + * Specify output that should be printed when the command runs. + * + * @param string $output + * @return $this + */ + public function expectsOutput($output) + { + $this->test->expectedOutput[] = $output; + + return $this; + } + + /** + * Specify output that should never be printed when the command runs. + * + * @param string $output + * @return $this + */ + public function doesntExpectOutput($output) + { + $this->test->unexpectedOutput[$output] = false; + + return $this; + } + + /** + * Specify a table that should be printed when the command runs. + * + * @param array $headers + * @param \Illuminate\Contracts\Support\Arrayable|array $rows + * @param string $tableStyle + * @param array $columnStyles + * @return $this + */ + public function expectsTable($headers, $rows, $tableStyle = 'default', array $columnStyles = []) + { + $table = (new Table($output = new BufferedOutput)) + ->setHeaders((array) $headers) + ->setRows($rows instanceof Arrayable ? $rows->toArray() : $rows) + ->setStyle($tableStyle); + + foreach ($columnStyles as $columnIndex => $columnStyle) { + $table->setColumnStyle($columnIndex, $columnStyle); + } + + $table->render(); + + $lines = array_filter( + explode(PHP_EOL, $output->fetch()) + ); + + foreach ($lines as $line) { + $this->expectsOutput($line); + } + + return $this; + } + + /** + * Assert that the command has the given exit code. + * + * @param int $exitCode + * @return $this + */ + public function assertExitCode($exitCode) + { + $this->expectedExitCode = $exitCode; + + return $this; + } + + /** + * Assert that the command does not have the given exit code. + * + * @param int $exitCode + * @return $this + */ + public function assertNotExitCode($exitCode) + { + $this->unexpectedExitCode = $exitCode; + + return $this; + } + + /** + * Assert that the command has the success exit code. + * + * @return $this + */ + public function assertSuccessful() + { + return $this->assertExitCode(Command::SUCCESS); + } + + /** + * Assert that the command does not have the success exit code. + * + * @return $this + */ + public function assertFailed() + { + return $this->assertNotExitCode(Command::SUCCESS); + } + + /** + * Execute the command. + * + * @return int + */ + public function execute() + { + return $this->run(); + } + + /** + * Execute the command. + * + * @return int + * + * @throws \Mockery\Exception\NoMatchingExpectationException + */ + public function run() + { + $this->hasExecuted = true; + + $mock = $this->mockConsoleOutput(); + + try { + $exitCode = $this->app->make(Kernel::class)->call($this->command, $this->parameters, $mock); + } catch (NoMatchingExpectationException $e) { + if ($e->getMethodName() === 'askQuestion') { + $this->test->fail('Unexpected question "'.$e->getActualArguments()[0]->getQuestion().'" was asked.'); + } + + throw $e; + } + + if ($this->expectedExitCode !== null) { + $this->test->assertEquals( + $this->expectedExitCode, $exitCode, + "Expected status code {$this->expectedExitCode} but received {$exitCode}." + ); + } elseif (! is_null($this->unexpectedExitCode)) { + $this->test->assertNotEquals( + $this->unexpectedExitCode, $exitCode, + "Unexpected status code {$this->unexpectedExitCode} was received." + ); + } + + $this->verifyExpectations(); + $this->flushExpectations(); + + return $exitCode; + } + + /** + * Determine if expected questions / choices / outputs are fulfilled. + * + * @return void + */ + protected function verifyExpectations() + { + if (count($this->test->expectedQuestions)) { + $this->test->fail('Question "'.Arr::first($this->test->expectedQuestions)[0].'" was not asked.'); + } + + if (count($this->test->expectedChoices) > 0) { + foreach ($this->test->expectedChoices as $question => $answers) { + $assertion = $answers['strict'] ? 'assertEquals' : 'assertEqualsCanonicalizing'; + + $this->test->{$assertion}( + $answers['expected'], + $answers['actual'], + 'Question "'.$question.'" has different options.' + ); + } + } + + if (count($this->test->expectedOutput)) { + $this->test->fail('Output "'.Arr::first($this->test->expectedOutput).'" was not printed.'); + } + + if ($output = array_search(true, $this->test->unexpectedOutput)) { + $this->test->fail('Output "'.$output.'" was printed.'); + } + } + + /** + * Mock the application's console output. + * + * @return \Mockery\MockInterface + */ + protected function mockConsoleOutput() + { + $mock = Mockery::mock(OutputStyle::class.'[askQuestion]', [ + (new ArrayInput($this->parameters)), $this->createABufferedOutputMock(), + ]); + + foreach ($this->test->expectedQuestions as $i => $question) { + $mock->shouldReceive('askQuestion') + ->once() + ->ordered() + ->with(Mockery::on(function ($argument) use ($question) { + if (isset($this->test->expectedChoices[$question[0]])) { + $this->test->expectedChoices[$question[0]]['actual'] = $argument->getAutocompleterValues(); + } + + return $argument->getQuestion() == $question[0]; + })) + ->andReturnUsing(function () use ($question, $i) { + unset($this->test->expectedQuestions[$i]); + + return $question[1]; + }); + } + + $this->app->bind(OutputStyle::class, function () use ($mock) { + return $mock; + }); + + return $mock; + } + + /** + * Create a mock for the buffered output. + * + * @return \Mockery\MockInterface + */ + private function createABufferedOutputMock() + { + $mock = Mockery::mock(BufferedOutput::class.'[doWrite]') + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + + foreach ($this->test->expectedOutput as $i => $output) { + $mock->shouldReceive('doWrite') + ->once() + ->ordered() + ->with($output, Mockery::any()) + ->andReturnUsing(function () use ($i) { + unset($this->test->expectedOutput[$i]); + }); + } + + foreach ($this->test->unexpectedOutput as $output => $displayed) { + $mock->shouldReceive('doWrite') + ->ordered() + ->with($output, Mockery::any()) + ->andReturnUsing(function () use ($output) { + $this->test->unexpectedOutput[$output] = true; + }); + } + + return $mock; + } + + /** + * Flush the expectations from the test case. + * + * @return void + */ + protected function flushExpectations() + { + $this->test->expectedOutput = []; + $this->test->unexpectedOutput = []; + $this->test->expectedTables = []; + $this->test->expectedQuestions = []; + $this->test->expectedChoices = []; + } + + /** + * Handle the object's destruction. + * + * @return void + */ + public function __destruct() + { + if ($this->hasExecuted) { + return; + } + + $this->run(); + } +} diff --git a/src/Illuminate/Testing/TestComponent.php b/src/Illuminate/Testing/TestComponent.php new file mode 100644 index 0000000000000000000000000000000000000000..21663aa8d2f3eeb8b8ab292932847d0f074f5ea1 --- /dev/null +++ b/src/Illuminate/Testing/TestComponent.php @@ -0,0 +1,167 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\SeeInOrder; +use Illuminate\View\Component; + +class TestComponent +{ + /** + * The original component. + * + * @var \Illuminate\View\Component + */ + public $component; + + /** + * The rendered component contents. + * + * @var string + */ + protected $rendered; + + /** + * Create a new test component instance. + * + * @param \Illuminate\View\Component $component + * @param \Illuminate\View\View $view + * @return void + */ + public function __construct($component, $view) + { + $this->component = $component; + + $this->rendered = $view->render(); + } + + /** + * Assert that the given string is contained within the rendered component. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertSee($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + + return $this; + } + + /** + * Assert that the given strings are contained in order within the rendered component. + * + * @param array $values + * @param bool $escape + * @return $this + */ + public function assertSeeInOrder(array $values, $escape = true) + { + $values = $escape ? array_map('e', ($values)) : $values; + + PHPUnit::assertThat($values, new SeeInOrder($this->rendered)); + + return $this; + } + + /** + * Assert that the given string is contained within the rendered component text. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertSeeText($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + + return $this; + } + + /** + * Assert that the given strings are contained in order within the rendered component text. + * + * @param array $values + * @param bool $escape + * @return $this + */ + public function assertSeeTextInOrder(array $values, $escape = true) + { + $values = $escape ? array_map('e', ($values)) : $values; + + PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + + return $this; + } + + /** + * Assert that the given string is not contained within the rendered component. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertDontSee($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + + return $this; + } + + /** + * Assert that the given string is not contained within the rendered component text. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertDontSeeText($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + + return $this; + } + + /** + * Get the string contents of the rendered component. + * + * @return string + */ + public function __toString() + { + return $this->rendered; + } + + /** + * Dynamically access properties on the underlying component. + * + * @param string $attribute + * @return mixed + */ + public function __get($attribute) + { + return $this->component->{$attribute}; + } + + /** + * Dynamically call methods on the underlying component. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->component->{$method}(...$parameters); + } +} diff --git a/src/Illuminate/Foundation/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php similarity index 59% rename from src/Illuminate/Foundation/Testing/TestResponse.php rename to src/Illuminate/Testing/TestResponse.php index db8e0ff02d3826b7c7639cb33b29db6374340bd9..b89af2630a6e037f7b58ef513c09c42e468c5b9e 100644 --- a/src/Illuminate/Foundation/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -1,19 +1,23 @@ <?php -namespace Illuminate\Foundation\Testing; +namespace Illuminate\Testing; use ArrayAccess; use Closure; use Illuminate\Contracts\View\View; use Illuminate\Cookie\CookieValuePrefix; use Illuminate\Database\Eloquent\Model; -use Illuminate\Foundation\Testing\Assert as PHPUnit; -use Illuminate\Foundation\Testing\Constraints\SeeInOrder; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Illuminate\Support\Traits\Tappable; +use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\SeeInOrder; +use Illuminate\Testing\Fluent\AssertableJson; use LogicException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -33,6 +37,13 @@ class TestResponse implements ArrayAccess */ public $baseResponse; + /** + * The collection of logged exceptions for the request. + * + * @var \Illuminate\Support\Collection + */ + protected $exceptions; + /** * The streamed content of the response. * @@ -49,6 +60,7 @@ class TestResponse implements ArrayAccess public function __construct($response) { $this->baseResponse = $response; + $this->exceptions = new Collection; } /** @@ -71,7 +83,7 @@ class TestResponse implements ArrayAccess { PHPUnit::assertTrue( $this->isSuccessful(), - 'Response status code ['.$this->getStatusCode().'] is not a successful status code.' + $this->statusMessageWithDetails('>=200, <300', $this->getStatusCode()) ); return $this; @@ -84,12 +96,7 @@ class TestResponse implements ArrayAccess */ public function assertOk() { - PHPUnit::assertTrue( - $this->isOk(), - 'Response status code ['.$this->getStatusCode().'] does not match expected 200 status code.' - ); - - return $this; + return $this->assertStatus(200); } /** @@ -99,14 +106,7 @@ class TestResponse implements ArrayAccess */ public function assertCreated() { - $actual = $this->getStatusCode(); - - PHPUnit::assertTrue( - 201 === $actual, - 'Response status code ['.$actual.'] does not match expected 201 status code.' - ); - - return $this; + return $this->assertStatus(201); } /** @@ -131,12 +131,7 @@ class TestResponse implements ArrayAccess */ public function assertNotFound() { - PHPUnit::assertTrue( - $this->isNotFound(), - 'Response status code ['.$this->getStatusCode().'] is not a not found status code.' - ); - - return $this; + return $this->assertStatus(404); } /** @@ -146,12 +141,7 @@ class TestResponse implements ArrayAccess */ public function assertForbidden() { - PHPUnit::assertTrue( - $this->isForbidden(), - 'Response status code ['.$this->getStatusCode().'] is not a forbidden status code.' - ); - - return $this; + return $this->assertStatus(403); } /** @@ -161,14 +151,17 @@ class TestResponse implements ArrayAccess */ public function assertUnauthorized() { - $actual = $this->getStatusCode(); - - PHPUnit::assertTrue( - 401 === $actual, - 'Response status code ['.$actual.'] is not an unauthorized status code.' - ); + return $this->assertStatus(401); + } - return $this; + /** + * Assert that the response has a 422 status code. + * + * @return $this + */ + public function assertUnprocessable() + { + return $this->assertStatus(422); } /** @@ -179,16 +172,92 @@ class TestResponse implements ArrayAccess */ public function assertStatus($status) { - $actual = $this->getStatusCode(); + $message = $this->statusMessageWithDetails($status, $actual = $this->getStatusCode()); - PHPUnit::assertTrue( - $actual === $status, - "Expected status code {$status} but received {$actual}." - ); + PHPUnit::assertSame($actual, $status, $message); return $this; } + /** + * Get an assertion message for a status assertion containing extra details when available. + * + * @param string|int $expected + * @param string|int $actual + * @return string + */ + protected function statusMessageWithDetails($expected, $actual) + { + $lastException = $this->exceptions->last(); + + if ($lastException) { + return $this->statusMessageWithException($expected, $actual, $lastException); + } + + if ($this->baseResponse instanceof RedirectResponse) { + $session = $this->baseResponse->getSession(); + + if (! is_null($session) && $session->has('errors')) { + return $this->statusMessageWithErrors($expected, $actual, $session->get('errors')->all()); + } + } + + if ($this->baseResponse->headers->get('Content-Type') === 'application/json') { + $testJson = new AssertableJsonString($this->getContent()); + + if (isset($testJson['errors'])) { + return $this->statusMessageWithErrors($expected, $actual, $testJson->json()); + } + } + + return "Expected response status code [{$expected}] but received {$actual}."; + } + + /** + * Get an assertion message for a status assertion that has an unexpected exception. + * + * @param string|int $expected + * @param string|int $actual + * @param \Throwable $exception + * @return string + */ + protected function statusMessageWithException($expected, $actual, $exception) + { + $exception = (string) $exception; + + return <<<EOF +Expected response status code [$expected] but received $actual. + +The following exception occurred during the request: + +$exception +EOF; + } + + /** + * Get an assertion message for a status assertion that contained errors. + * + * @param string|int $expected + * @param string|int $actual + * @param array $errors + * @return string + */ + protected function statusMessageWithErrors($expected, $actual, $errors) + { + $errors = $this->baseResponse->headers->get('Content-Type') === 'application/json' + ? json_encode($errors, JSON_PRETTY_PRINT) + : implode(PHP_EOL, Arr::flatten($errors)); + + return <<<EOF +Expected response status code [$expected] but received $actual. + +The following errors occurred during the request: + +$errors + +EOF; + } + /** * Assert whether the response is redirecting to a given URI. * @@ -198,7 +267,8 @@ class TestResponse implements ArrayAccess public function assertRedirect($uri = null) { PHPUnit::assertTrue( - $this->isRedirect(), 'Response status code ['.$this->getStatusCode().'] is not a redirect status code.' + $this->isRedirect(), + $this->statusMessageWithDetails('201, 301, 302, 303, 307, 308', $this->getStatusCode()), ); if (! is_null($uri)) { @@ -208,6 +278,64 @@ class TestResponse implements ArrayAccess return $this; } + /** + * Assert whether the response is redirecting to a URI that contains the given URI. + * + * @param string $uri + * @return $this + */ + public function assertRedirectContains($uri) + { + PHPUnit::assertTrue( + $this->isRedirect(), + $this->statusMessageWithDetails('201, 301, 302, 303, 307, 308', $this->getStatusCode()), + ); + + PHPUnit::assertTrue( + Str::contains($this->headers->get('Location'), $uri), 'Redirect location ['.$this->headers->get('Location').'] does not contain ['.$uri.'].' + ); + + return $this; + } + + /** + * Assert whether the response is redirecting to a given signed route. + * + * @param string|null $name + * @param mixed $parameters + * @return $this + */ + public function assertRedirectToSignedRoute($name = null, $parameters = []) + { + if (! is_null($name)) { + $uri = route($name, $parameters); + } + + PHPUnit::assertTrue( + $this->isRedirect(), + $this->statusMessageWithDetails('201, 301, 302, 303, 307, 308', $this->getStatusCode()), + ); + + $request = Request::create($this->headers->get('Location')); + + PHPUnit::assertTrue( + $request->hasValidSignature(), 'The response is not a redirect to a signed route.' + ); + + if (! is_null($name)) { + $expectedUri = rtrim($request->fullUrlWithQuery([ + 'signature' => null, + 'expires' => null, + ]), '?'); + + PHPUnit::assertEquals( + app('url')->to($uri), $expectedUri + ); + } + + return $this; + } + /** * Asserts that the response contains the given header and equals the optional value. * @@ -234,7 +362,7 @@ class TestResponse implements ArrayAccess } /** - * Asserts that the response does not contains the given header. + * Asserts that the response does not contain the given header. * * @param string $headerName * @return $this @@ -263,6 +391,54 @@ class TestResponse implements ArrayAccess return $this; } + /** + * Assert that the response offers a file download. + * + * @param string|null $filename + * @return $this + */ + public function assertDownload($filename = null) + { + $contentDisposition = explode(';', $this->headers->get('content-disposition')); + + if (trim($contentDisposition[0]) !== 'attachment') { + PHPUnit::fail( + 'Response does not offer a file download.'.PHP_EOL. + 'Disposition ['.trim($contentDisposition[0]).'] found in header, [attachment] expected.' + ); + } + + if (! is_null($filename)) { + if (isset($contentDisposition[1]) && + trim(explode('=', $contentDisposition[1])[0]) !== 'filename') { + PHPUnit::fail( + 'Unsupported Content-Disposition header provided.'.PHP_EOL. + 'Disposition ['.trim(explode('=', $contentDisposition[1])[0]).'] found in header, [filename] expected.' + ); + } + + $message = "Expected file [{$filename}] is not present in Content-Disposition header."; + + if (! isset($contentDisposition[1])) { + PHPUnit::fail($message); + } else { + PHPUnit::assertSame( + $filename, + isset(explode('=', $contentDisposition[1])[1]) + ? trim(explode('=', $contentDisposition[1])[1], " \"'") + : '', + $message + ); + + return $this; + } + } else { + PHPUnit::assertTrue(true); + + return $this; + } + } + /** * Asserts that the response contains the given cookie and equals the optional value. * @@ -327,7 +503,7 @@ class TestResponse implements ArrayAccess $expiresAt = Carbon::createFromTimestamp($cookie->getExpiresTime()); PHPUnit::assertTrue( - $expiresAt->lessThan(Carbon::now()), + 0 !== $cookie->getExpiresTime() && $expiresAt->lessThan(Carbon::now()), "Cookie [{$cookieName}] is not expired, it expires at [{$expiresAt}]." ); @@ -350,7 +526,7 @@ class TestResponse implements ArrayAccess $expiresAt = Carbon::createFromTimestamp($cookie->getExpiresTime()); PHPUnit::assertTrue( - $expiresAt->greaterThan(Carbon::now()), + 0 === $cookie->getExpiresTime() || $expiresAt->greaterThan(Carbon::now()), "Cookie [{$cookieName}] is expired, it expired at [{$expiresAt}]." ); @@ -358,7 +534,7 @@ class TestResponse implements ArrayAccess } /** - * Asserts that the response does not contains the given cookie. + * Asserts that the response does not contain the given cookie. * * @param string $cookieName * @return $this @@ -379,7 +555,7 @@ class TestResponse implements ArrayAccess * @param string $cookieName * @return \Symfony\Component\HttpFoundation\Cookie|null */ - protected function getCookie($cookieName) + public function getCookie($cookieName) { foreach ($this->headers->getCookies() as $cookie) { if ($cookie->getName() === $cookieName) { @@ -389,14 +565,21 @@ class TestResponse implements ArrayAccess } /** - * Assert that the given string is contained within the response. + * Assert that the given string or array of strings are contained within the response. * - * @param string $value + * @param string|array $value + * @param bool $escape * @return $this */ - public function assertSee($value) + public function assertSee($value, $escape = true) { - PHPUnit::assertStringContainsString((string) $value, $this->getContent()); + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $this->getContent()); + } return $this; } @@ -405,24 +588,36 @@ class TestResponse implements ArrayAccess * Assert that the given strings are contained in order within the response. * * @param array $values + * @param bool $escape * @return $this */ - public function assertSeeInOrder(array $values) + public function assertSeeInOrder(array $values, $escape = true) { + $values = $escape ? array_map('e', ($values)) : $values; + PHPUnit::assertThat($values, new SeeInOrder($this->getContent())); return $this; } /** - * Assert that the given string is contained within the response text. + * Assert that the given string or array of strings are contained within the response text. * - * @param string $value + * @param string|array $value + * @param bool $escape * @return $this */ - public function assertSeeText($value) + public function assertSeeText($value, $escape = true) { - PHPUnit::assertStringContainsString((string) $value, strip_tags($this->getContent())); + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + tap(strip_tags($this->getContent()), function ($content) use ($values) { + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $content); + } + }); return $this; } @@ -431,37 +626,56 @@ class TestResponse implements ArrayAccess * Assert that the given strings are contained in order within the response text. * * @param array $values + * @param bool $escape * @return $this */ - public function assertSeeTextInOrder(array $values) + public function assertSeeTextInOrder(array $values, $escape = true) { + $values = $escape ? array_map('e', ($values)) : $values; + PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->getContent()))); return $this; } /** - * Assert that the given string is not contained within the response. + * Assert that the given string or array of strings are not contained within the response. * - * @param string $value + * @param string|array $value + * @param bool $escape * @return $this */ - public function assertDontSee($value) + public function assertDontSee($value, $escape = true) { - PHPUnit::assertStringNotContainsString((string) $value, $this->getContent()); + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $this->getContent()); + } return $this; } /** - * Assert that the given string is not contained within the response text. + * Assert that the given string or array of strings are not contained within the response text. * - * @param string $value + * @param string|array $value + * @param bool $escape * @return $this */ - public function assertDontSeeText($value) + public function assertDontSeeText($value, $escape = true) { - PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->getContent())); + $value = Arr::wrap($value); + + $values = $escape ? array_map('e', ($value)) : $value; + + tap(strip_tags($this->getContent()), function ($content) use ($values) { + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $content); + } + }); return $this; } @@ -469,52 +683,39 @@ class TestResponse implements ArrayAccess /** * Assert that the response is a superset of the given JSON. * - * @param array $data + * @param array|callable $value * @param bool $strict * @return $this */ - public function assertJson(array $data, $strict = false) + public function assertJson($value, $strict = false) { - PHPUnit::assertArraySubset( - $data, $this->decodeResponseJson(), $strict, $this->assertJsonMessage($data) - ); + $json = $this->decodeResponseJson(); - return $this; - } + if (is_array($value)) { + $json->assertSubset($value, $strict); + } else { + $assert = AssertableJson::fromAssertableJsonString($json); - /** - * Get the assertion message for assertJson. - * - * @param array $data - * @return string - */ - protected function assertJsonMessage(array $data) - { - $expected = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $value($assert); - $actual = json_encode($this->decodeResponseJson(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (Arr::isAssoc($assert->toArray())) { + $assert->interacted(); + } + } - return 'Unable to find JSON: '.PHP_EOL.PHP_EOL. - "[{$expected}]".PHP_EOL.PHP_EOL. - 'within response JSON:'.PHP_EOL.PHP_EOL. - "[{$actual}].".PHP_EOL.PHP_EOL; + return $this; } /** - * Assert that the expected value exists at the given path in the response. + * Assert that the expected value and type exists at the given path in the response. * * @param string $path * @param mixed $expect - * @param bool $strict * @return $this */ - public function assertJsonPath($path, $expect, $strict = false) + public function assertJsonPath($path, $expect) { - if ($strict) { - PHPUnit::assertSame($expect, $this->json($path)); - } else { - PHPUnit::assertEquals($expect, $this->json($path)); - } + $this->decodeResponseJson()->assertPath($path, $expect); return $this; } @@ -527,11 +728,20 @@ class TestResponse implements ArrayAccess */ public function assertExactJson(array $data) { - $actual = json_encode(Arr::sortRecursive( - (array) $this->decodeResponseJson() - )); + $this->decodeResponseJson()->assertExact($data); - PHPUnit::assertEquals(json_encode(Arr::sortRecursive($data)), $actual); + return $this; + } + + /** + * Assert that the response has the similar JSON as given. + * + * @param array $data + * @return $this + */ + public function assertSimilarJson(array $data) + { + $this->decodeResponseJson()->assertSimilar($data); return $this; } @@ -544,21 +754,7 @@ class TestResponse implements ArrayAccess */ public function assertJsonFragment(array $data) { - $actual = json_encode(Arr::sortRecursive( - (array) $this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $expected = $this->jsonSearchStrings($key, $value); - - PHPUnit::assertTrue( - Str::contains($actual, $expected), - 'Unable to find JSON fragment: '.PHP_EOL.PHP_EOL. - '['.json_encode([$key => $value]).']'.PHP_EOL.PHP_EOL. - 'within'.PHP_EOL.PHP_EOL. - "[{$actual}]." - ); - } + $this->decodeResponseJson()->assertFragment($data); return $this; } @@ -572,25 +768,7 @@ class TestResponse implements ArrayAccess */ public function assertJsonMissing(array $data, $exact = false) { - if ($exact) { - return $this->assertJsonMissingExact($data); - } - - $actual = json_encode(Arr::sortRecursive( - (array) $this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $unexpected = $this->jsonSearchStrings($key, $value); - - PHPUnit::assertFalse( - Str::contains($actual, $unexpected), - 'Found unexpected JSON fragment: '.PHP_EOL.PHP_EOL. - '['.json_encode([$key => $value]).']'.PHP_EOL.PHP_EOL. - 'within'.PHP_EOL.PHP_EOL. - "[{$actual}]." - ); - } + $this->decodeResponseJson()->assertMissing($data, $exact); return $this; } @@ -603,42 +781,9 @@ class TestResponse implements ArrayAccess */ public function assertJsonMissingExact(array $data) { - $actual = json_encode(Arr::sortRecursive( - (array) $this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $unexpected = $this->jsonSearchStrings($key, $value); + $this->decodeResponseJson()->assertMissingExact($data); - if (! Str::contains($actual, $unexpected)) { - return $this; - } - } - - PHPUnit::fail( - 'Found unexpected JSON fragment: '.PHP_EOL.PHP_EOL. - '['.json_encode($data).']'.PHP_EOL.PHP_EOL. - 'within'.PHP_EOL.PHP_EOL. - "[{$actual}]." - ); - } - - /** - * Get the strings we need to search for when examining the JSON. - * - * @param string $key - * @param string $value - * @return array - */ - protected function jsonSearchStrings($key, $value) - { - $needle = substr(json_encode([$key => $value]), 1, -1); - - return [ - $needle.']', - $needle.'}', - $needle.',', - ]; + return $this; } /** @@ -650,29 +795,7 @@ class TestResponse implements ArrayAccess */ public function assertJsonStructure(array $structure = null, $responseData = null) { - if (is_null($structure)) { - return $this->assertExactJson($this->json()); - } - - if (is_null($responseData)) { - $responseData = $this->decodeResponseJson(); - } - - foreach ($structure as $key => $value) { - if (is_array($value) && $key === '*') { - PHPUnit::assertIsArray($responseData); - - foreach ($responseData as $responseDataItem) { - $this->assertJsonStructure($structure['*'], $responseDataItem); - } - } elseif (is_array($value)) { - PHPUnit::assertArrayHasKey($key, $responseData); - - $this->assertJsonStructure($structure[$key], $responseData[$key]); - } else { - PHPUnit::assertArrayHasKey($value, $responseData); - } - } + $this->decodeResponseJson()->assertStructure($structure, $responseData); return $this; } @@ -686,19 +809,7 @@ class TestResponse implements ArrayAccess */ public function assertJsonCount(int $count, $key = null) { - if (! is_null($key)) { - PHPUnit::assertCount( - $count, data_get($this->json(), $key), - "Failed to assert that the response count matched the expected {$count}" - ); - - return $this; - } - - PHPUnit::assertCount($count, - $this->json(), - "Failed to assert that the response count matched the expected {$count}" - ); + $this->decodeResponseJson()->assertCount($count, $key); return $this; } @@ -716,7 +827,7 @@ class TestResponse implements ArrayAccess PHPUnit::assertNotEmpty($errors, 'No validation errors were provided.'); - $jsonErrors = $this->json()[$responseKey] ?? []; + $jsonErrors = Arr::get($this->json(), $responseKey) ?? []; $errorMessage = $jsonErrors ? 'Response has the following JSON validation errors:'. @@ -724,34 +835,61 @@ class TestResponse implements ArrayAccess : 'Response does not have JSON validation errors.'; foreach ($errors as $key => $value) { - PHPUnit::assertArrayHasKey( - (is_int($key)) ? $value : $key, - $jsonErrors, - "Failed to find a validation error in the response for key: '{$value}'".PHP_EOL.PHP_EOL.$errorMessage - ); + if (is_int($key)) { + $this->assertJsonValidationErrorFor($value, $responseKey); - if (! is_int($key)) { - $hasError = false; + continue; + } + + $this->assertJsonValidationErrorFor($key, $responseKey); + + foreach (Arr::wrap($value) as $expectedMessage) { + $errorMissing = true; foreach (Arr::wrap($jsonErrors[$key]) as $jsonErrorMessage) { - if (Str::contains($jsonErrorMessage, $value)) { - $hasError = true; + if (Str::contains($jsonErrorMessage, $expectedMessage)) { + $errorMissing = false; break; } } + } - if (! $hasError) { - PHPUnit::fail( - "Failed to find a validation error in the response for key and message: '$key' => '$value'".PHP_EOL.PHP_EOL.$errorMessage - ); - } + if ($errorMissing) { + PHPUnit::fail( + "Failed to find a validation error in the response for key and message: '$key' => '$expectedMessage'".PHP_EOL.PHP_EOL.$errorMessage + ); } } return $this; } + /** + * Assert the response has any JSON validation errors for the given key. + * + * @param string $key + * @param string $responseKey + * @return $this + */ + public function assertJsonValidationErrorFor($key, $responseKey = 'errors') + { + $jsonErrors = Arr::get($this->json(), $responseKey) ?? []; + + $errorMessage = $jsonErrors + ? 'Response has the following JSON validation errors:'. + PHP_EOL.PHP_EOL.json_encode($jsonErrors, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).PHP_EOL + : 'Response does not have JSON validation errors.'; + + PHPUnit::assertArrayHasKey( + $key, + $jsonErrors, + "Failed to find a validation error in the response for key: '{$key}'".PHP_EOL.PHP_EOL.$errorMessage + ); + + return $this; + } + /** * Assert that the response has no JSON validation errors for the given keys. * @@ -769,13 +907,13 @@ class TestResponse implements ArrayAccess $json = $this->json(); - if (! array_key_exists($responseKey, $json)) { - PHPUnit::assertArrayNotHasKey($responseKey, $json); + if (! Arr::has($json, $responseKey)) { + PHPUnit::assertTrue(true); return $this; } - $errors = $json[$responseKey]; + $errors = Arr::get($json, $responseKey, []); if (is_null($keys) && count($errors) > 0) { PHPUnit::fail( @@ -797,12 +935,15 @@ class TestResponse implements ArrayAccess /** * Validate and return the decoded response JSON. * - * @param string|null $key - * @return mixed + * @return \Illuminate\Testing\AssertableJsonString + * + * @throws \Throwable */ - public function decodeResponseJson($key = null) + public function decodeResponseJson() { - $decodedResponse = json_decode($this->getContent(), true); + $testJson = new AssertableJsonString($this->getContent()); + + $decodedResponse = $testJson->json(); if (is_null($decodedResponse) || $decodedResponse === false) { if ($this->exception) { @@ -812,7 +953,7 @@ class TestResponse implements ArrayAccess } } - return data_get($decodedResponse, $key); + return $testJson; } /** @@ -823,7 +964,7 @@ class TestResponse implements ArrayAccess */ public function json($key = null) { - return $this->decodeResponseJson($key); + return $this->decodeResponseJson()->json($key); } /** @@ -923,13 +1064,122 @@ class TestResponse implements ArrayAccess */ protected function ensureResponseHasView() { - if (! isset($this->original) || ! $this->original instanceof View) { + if (! $this->responseHasView()) { return PHPUnit::fail('The response is not a view.'); } return $this; } + /** + * Determine if the original response is a view. + * + * @return bool + */ + protected function responseHasView() + { + return isset($this->original) && $this->original instanceof View; + } + + /** + * Assert that the given keys do not have validation errors. + * + * @param string|array|null $keys + * @param string $errorBag + * @param string $responseKey + * @return $this + */ + public function assertValid($keys = null, $errorBag = 'default', $responseKey = 'errors') + { + if ($this->baseResponse->headers->get('Content-Type') === 'application/json') { + return $this->assertJsonMissingValidationErrors($keys, $responseKey); + } + + if ($this->session()->get('errors')) { + $errors = $this->session()->get('errors')->getBag($errorBag)->getMessages(); + } else { + $errors = []; + } + + if (empty($errors)) { + PHPUnit::assertTrue(true); + + return $this; + } + + if (is_null($keys) && count($errors) > 0) { + PHPUnit::fail( + 'Response has unexpected validation errors: '.PHP_EOL.PHP_EOL. + json_encode($errors, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + } + + foreach (Arr::wrap($keys) as $key) { + PHPUnit::assertFalse( + isset($errors[$key]), + "Found unexpected validation error for key: '{$key}'" + ); + } + + return $this; + } + + /** + * Assert that the response has the given validation errors. + * + * @param string|array|null $errors + * @param string $errorBag + * @param string $responseKey + * @return $this + */ + public function assertInvalid($errors = null, + $errorBag = 'default', + $responseKey = 'errors') + { + if ($this->baseResponse->headers->get('Content-Type') === 'application/json') { + return $this->assertJsonValidationErrors($errors, $responseKey); + } + + $this->assertSessionHas('errors'); + + $keys = (array) $errors; + + $sessionErrors = $this->session()->get('errors')->getBag($errorBag)->getMessages(); + + $errorMessage = $sessionErrors + ? 'Response has the following validation errors in the session:'. + PHP_EOL.PHP_EOL.json_encode($sessionErrors, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).PHP_EOL + : 'Response does not have validation errors in the session.'; + + foreach (Arr::wrap($errors) as $key => $value) { + PHPUnit::assertArrayHasKey( + (is_int($key)) ? $value : $key, + $sessionErrors, + "Failed to find a validation error in session for key: '{$value}'".PHP_EOL.PHP_EOL.$errorMessage + ); + + if (! is_int($key)) { + $hasError = false; + + foreach (Arr::wrap($sessionErrors[$key]) as $sessionErrorMessage) { + if (Str::contains($sessionErrorMessage, $value)) { + $hasError = true; + + break; + } + } + + if (! $hasError) { + PHPUnit::fail( + "Failed to find a validation error for key and message: '$key' => '$value'".PHP_EOL.PHP_EOL.$errorMessage + ); + } + } + } + + return $this; + } + /** * Assert that the session has a given value. * @@ -1138,6 +1388,43 @@ class TestResponse implements ArrayAccess return app('session.store'); } + /** + * Dump the content from the response and end the script. + * + * @return never + */ + public function dd() + { + $this->dump(); + + exit(1); + } + + /** + * Dump the headers from the response and end the script. + * + * @return never + */ + public function ddHeaders() + { + $this->dumpHeaders(); + + exit(1); + } + + /** + * Dump the session from the response and end the script. + * + * @param string|array $keys + * @return never + */ + public function ddSession($keys = []) + { + $this->dumpSession($keys); + + exit(1); + } + /** * Dump the content from the response. * @@ -1204,11 +1491,30 @@ class TestResponse implements ArrayAccess PHPUnit::fail('The response is not a streamed response.'); } - ob_start(); + ob_start(function (string $buffer): string { + $this->streamedContent .= $buffer; + + return ''; + }); $this->sendContent(); - return $this->streamedContent = ob_get_clean(); + ob_end_clean(); + + return $this->streamedContent; + } + + /** + * Set the previous exceptions on the response. + * + * @param \Illuminate\Support\Collection $exceptions + * @return $this + */ + public function withExceptions(Collection $exceptions) + { + $this->exceptions = $exceptions; + + return $this; } /** @@ -1239,9 +1545,12 @@ class TestResponse implements ArrayAccess * @param string $offset * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { - return isset($this->json()[$offset]); + return $this->responseHasView() + ? isset($this->original->gatherData()[$offset]) + : isset($this->json()[$offset]); } /** @@ -1250,9 +1559,12 @@ class TestResponse implements ArrayAccess * @param string $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { - return $this->json()[$offset]; + return $this->responseHasView() + ? $this->viewData($offset) + : $this->json()[$offset]; } /** @@ -1264,6 +1576,7 @@ class TestResponse implements ArrayAccess * * @throws \LogicException */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { throw new LogicException('Response data may not be mutated using array access.'); @@ -1277,6 +1590,7 @@ class TestResponse implements ArrayAccess * * @throws \LogicException */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { throw new LogicException('Response data may not be mutated using array access.'); diff --git a/src/Illuminate/Testing/TestView.php b/src/Illuminate/Testing/TestView.php new file mode 100644 index 0000000000000000000000000000000000000000..3642c3f216a1af6f1cbc791afbd53044548f974c --- /dev/null +++ b/src/Illuminate/Testing/TestView.php @@ -0,0 +1,145 @@ +<?php + +namespace Illuminate\Testing; + +use Illuminate\Support\Traits\Macroable; +use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\SeeInOrder; +use Illuminate\View\View; + +class TestView +{ + use Macroable; + + /** + * The original view. + * + * @var \Illuminate\View\View + */ + protected $view; + + /** + * The rendered view contents. + * + * @var string + */ + protected $rendered; + + /** + * Create a new test view instance. + * + * @param \Illuminate\View\View $view + * @return void + */ + public function __construct(View $view) + { + $this->view = $view; + $this->rendered = $view->render(); + } + + /** + * Assert that the given string is contained within the view. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertSee($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + + return $this; + } + + /** + * Assert that the given strings are contained in order within the view. + * + * @param array $values + * @param bool $escape + * @return $this + */ + public function assertSeeInOrder(array $values, $escape = true) + { + $values = $escape ? array_map('e', ($values)) : $values; + + PHPUnit::assertThat($values, new SeeInOrder($this->rendered)); + + return $this; + } + + /** + * Assert that the given string is contained within the view text. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertSeeText($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + + return $this; + } + + /** + * Assert that the given strings are contained in order within the view text. + * + * @param array $values + * @param bool $escape + * @return $this + */ + public function assertSeeTextInOrder(array $values, $escape = true) + { + $values = $escape ? array_map('e', ($values)) : $values; + + PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + + return $this; + } + + /** + * Assert that the given string is not contained within the view. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertDontSee($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + + return $this; + } + + /** + * Assert that the given string is not contained within the view text. + * + * @param string $value + * @param bool $escape + * @return $this + */ + public function assertDontSeeText($value, $escape = true) + { + $value = $escape ? e($value) : $value; + + PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + + return $this; + } + + /** + * Get the string contents of the rendered view. + * + * @return string + */ + public function __toString() + { + return $this->rendered; + } +} diff --git a/src/Illuminate/Testing/composer.json b/src/Illuminate/Testing/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..41771b3b0dd505a0116efdc848338a768f4356cb --- /dev/null +++ b/src/Illuminate/Testing/composer.json @@ -0,0 +1,45 @@ +{ + "name": "illuminate/testing", + "description": "The Illuminate Testing package.", + "license": "MIT", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "require": { + "php": "^7.3|^8.0", + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0" + }, + "autoload": { + "psr-4": { + "Illuminate\\Testing\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "suggest": { + "brianium/paratest": "Required to run tests in parallel (^6.0).", + "illuminate/console": "Required to assert console commands (^8.0).", + "illuminate/database": "Required to assert databases (^8.0).", + "illuminate/http": "Required to assert responses (^8.0).", + "mockery/mockery": "Required to use mocking (^1.4.4).", + "phpunit/phpunit": "Required to use assertions and run tests (^8.5.19|^9.5.8)." + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev" +} diff --git a/src/Illuminate/Translation/FileLoader.php b/src/Illuminate/Translation/FileLoader.php index 17f6e59f0b0eb6c41939c672feb6f40cd26d860f..f359a8e5584d53c9b7252c9bb6d30237b0e84f96 100755 --- a/src/Illuminate/Translation/FileLoader.php +++ b/src/Illuminate/Translation/FileLoader.php @@ -164,6 +164,16 @@ class FileLoader implements Loader $this->hints[$namespace] = $hint; } + /** + * Get an array of all the registered namespaces. + * + * @return array + */ + public function namespaces() + { + return $this->hints; + } + /** * Add a new JSON path to the loader. * @@ -176,12 +186,12 @@ class FileLoader implements Loader } /** - * Get an array of all the registered namespaces. + * Get an array of all the registered paths to JSON translation files. * * @return array */ - public function namespaces() + public function jsonPaths() { - return $this->hints; + return $this->jsonPaths; } } diff --git a/src/Illuminate/Translation/MessageSelector.php b/src/Illuminate/Translation/MessageSelector.php index c1328d59342d2b4d86752e8b224a6dbc13753349..177fa12f484312a3500a2b1899d66fe45f3df3b4 100755 --- a/src/Illuminate/Translation/MessageSelector.php +++ b/src/Illuminate/Translation/MessageSelector.php @@ -61,7 +61,7 @@ class MessageSelector preg_match('/^[\{\[]([^\[\]\{\}]*)[\}\]](.*)/s', $part, $matches); if (count($matches) !== 3) { - return; + return null; } $condition = $matches[1]; diff --git a/src/Illuminate/Translation/Translator.php b/src/Illuminate/Translation/Translator.php index 0f1606e8493ef8062d085ba3c4dcabce8c32e3cc..cc36dbe9cc7b992f5473778dfbf995e8e18f0ddf 100755 --- a/src/Illuminate/Translation/Translator.php +++ b/src/Illuminate/Translation/Translator.php @@ -6,7 +6,6 @@ use Countable; use Illuminate\Contracts\Translation\Loader; use Illuminate\Contracts\Translation\Translator as TranslatorContract; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; use Illuminate\Support\NamespacedItemResolver; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; @@ -125,7 +124,7 @@ class Translator extends NamespacedItemResolver implements TranslatorContract if (! is_null($line = $this->getLine( $namespace, $group, $locale, $item, $replace ))) { - return $line ?? $key; + return $line; } } } @@ -216,30 +215,15 @@ class Translator extends NamespacedItemResolver implements TranslatorContract return $line; } - $replace = $this->sortReplacements($replace); + $shouldReplace = []; foreach ($replace as $key => $value) { - $line = str_replace( - [':'.$key, ':'.Str::upper($key), ':'.Str::ucfirst($key)], - [$value, Str::upper($value), Str::ucfirst($value)], - $line - ); + $shouldReplace[':'.Str::ucfirst($key ?? '')] = Str::ucfirst($value ?? ''); + $shouldReplace[':'.Str::upper($key ?? '')] = Str::upper($value ?? ''); + $shouldReplace[':'.$key] = $value; } - return $line; - } - - /** - * Sort the replacements array. - * - * @param array $replace - * @return array - */ - protected function sortReplacements(array $replace) - { - return (new Collection($replace))->sortBy(function ($value, $key) { - return mb_strlen($key) * -1; - })->all(); + return strtr($line, $shouldReplace); } /** @@ -405,6 +389,8 @@ class Translator extends NamespacedItemResolver implements TranslatorContract * * @param string $locale * @return void + * + * @throws \InvalidArgumentException */ public function setLocale($locale) { diff --git a/src/Illuminate/Translation/composer.json b/src/Illuminate/Translation/composer.json index 598fdde394fa6296f2942522872ccf4ec4b5ccd0..ccd7142499d6b55092910451460b2f83f0ccf769 100755 --- a/src/Illuminate/Translation/composer.json +++ b/src/Illuminate/Translation/composer.json @@ -14,11 +14,13 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/contracts": "^6.0", - "illuminate/filesystem": "^6.0", - "illuminate/support": "^6.0" + "illuminate/collections": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/filesystem": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -27,7 +29,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/src/Illuminate/Validation/Concerns/FilterEmailValidation.php b/src/Illuminate/Validation/Concerns/FilterEmailValidation.php index 88ca1546c5ee75b6509998e0ec85c4cc13f35950..2d21b6c5b27d546fc6c4362e68d80d90adb981b6 100644 --- a/src/Illuminate/Validation/Concerns/FilterEmailValidation.php +++ b/src/Illuminate/Validation/Concerns/FilterEmailValidation.php @@ -7,6 +7,34 @@ use Egulias\EmailValidator\Validation\EmailValidation; class FilterEmailValidation implements EmailValidation { + /** + * The flags to pass to the filter_var function. + * + * @var int|null + */ + protected $flags; + + /** + * Create a new validation instance. + * + * @param int $flags + * @return void + */ + public function __construct($flags = null) + { + $this->flags = $flags; + } + + /** + * Create a new instance which allows any unicode characters in local-part. + * + * @return static + */ + public static function unicode() + { + return new static(FILTER_FLAG_EMAIL_UNICODE); + } + /** * Returns true if the given email is valid. * @@ -16,7 +44,9 @@ class FilterEmailValidation implements EmailValidation */ public function isValid($email, EmailLexer $emailLexer) { - return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + return is_null($this->flags) + ? filter_var($email, FILTER_VALIDATE_EMAIL) !== false + : filter_var($email, FILTER_VALIDATE_EMAIL, $this->flags) !== false; } /** diff --git a/src/Illuminate/Validation/Concerns/FormatsMessages.php b/src/Illuminate/Validation/Concerns/FormatsMessages.php index 9d011892aaf75aa66559cdd148ee6a17d34d467b..c7c9a1dcb8c6852d17ad524a9c422ca84e5bfe05 100644 --- a/src/Illuminate/Validation/Concerns/FormatsMessages.php +++ b/src/Illuminate/Validation/Concerns/FormatsMessages.php @@ -20,6 +20,10 @@ trait FormatsMessages */ protected function getMessage($attribute, $rule) { + $attributeWithPlaceholders = $attribute; + + $attribute = $this->replacePlaceholderInString($attribute); + $inlineMessage = $this->getInlineMessage($attribute, $rule); // First we will retrieve the custom message for the validation rule if one @@ -46,7 +50,7 @@ trait FormatsMessages // specific error message for the type of attribute being validated such // as a number, file or string which all have different message types. elseif (in_array($rule, $this->sizeRules)) { - return $this->getSizeMessage($attribute, $rule); + return $this->getSizeMessage($attributeWithPlaceholders, $rule); } // Finally, if no developer specified messages have been set, and no other @@ -54,7 +58,7 @@ trait FormatsMessages // messages out of the translator service for this validation rule. $key = "validation.{$lowerRule}"; - if ($key != ($value = $this->translator->get($key))) { + if ($key !== ($value = $this->translator->get($key))) { return $value; } @@ -116,7 +120,7 @@ trait FormatsMessages } /** - * Get the custom error message from translator. + * Get the custom error message from the translator. * * @param string $key * @return string @@ -307,7 +311,7 @@ trait FormatsMessages $actualValue = $this->getValue($attribute); if (is_scalar($actualValue) || is_null($actualValue)) { - $message = str_replace(':input', $actualValue, $message); + $message = str_replace(':input', $this->getDisplayableValue($attribute, $actualValue), $message); } return $message; @@ -336,7 +340,11 @@ trait FormatsMessages return $value ? 'true' : 'false'; } - return $value; + if (is_null($value)) { + return 'empty'; + } + + return (string) $value; } /** diff --git a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php index f5c3eb00c4e0af5d82546e478a0a7be06ad2c31d..e9732749ed37eb4d1e1d590a2202806cb3dc0373 100644 --- a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php @@ -6,6 +6,42 @@ use Illuminate\Support\Arr; trait ReplacesAttributes { + /** + * Replace all place-holders for the accepted_if rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceAcceptedIf($message, $attribute, $rule, $parameters) + { + $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); + + $parameters[0] = $this->getDisplayableAttribute($parameters[0]); + + return str_replace([':other', ':value'], $parameters, $message); + } + + /** + * Replace all place-holders for the declined_if rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceDeclinedIf($message, $attribute, $rule, $parameters) + { + $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); + + $parameters[0] = $this->getDisplayableAttribute($parameters[0]); + + return str_replace([':other', ':value'], $parameters, $message); + } + /** * Replace all place-holders for the between rule. * @@ -104,6 +140,20 @@ trait ReplacesAttributes return str_replace(':max', $parameters[0], $message); } + /** + * Replace all place-holders for the multiple_of rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceMultipleOf($message, $attribute, $rule, $parameters) + { + return str_replace(':value', $parameters[0] ?? '', $message); + } + /** * Replace all place-holders for the in rule. * @@ -150,6 +200,24 @@ trait ReplacesAttributes return str_replace(':other', $this->getDisplayableAttribute($parameters[0]), $message); } + /** + * Replace all place-holders for the required_array_keys rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceRequiredArrayKeys($message, $attribute, $rule, $parameters) + { + foreach ($parameters as &$parameter) { + $parameter = $this->getDisplayableValue($attribute, $parameter); + } + + return str_replace(':values', implode(', ', $parameters), $message); + } + /** * Replace all place-holders for the mimetypes rule. * @@ -360,6 +428,60 @@ trait ReplacesAttributes return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); } + /** + * Replace all place-holders for the prohibited_if rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceProhibitedIf($message, $attribute, $rule, $parameters) + { + $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); + + $parameters[0] = $this->getDisplayableAttribute($parameters[0]); + + return str_replace([':other', ':value'], $parameters, $message); + } + + /** + * Replace all place-holders for the prohibited_unless rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceProhibitedUnless($message, $attribute, $rule, $parameters) + { + $other = $this->getDisplayableAttribute($parameters[0]); + + $values = []; + + foreach (array_slice($parameters, 1) as $value) { + $values[] = $this->getDisplayableValue($parameters[0], $value); + } + + return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); + } + + /** + * Replace all place-holders for the prohibited_with rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceProhibits($message, $attribute, $rule, $parameters) + { + return str_replace(':other', implode(' / ', $this->getAttributeList($parameters)), $message); + } + /** * Replace all place-holders for the same rule. * diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 84e0964ba1479c0df21294a9f2c4983a944ab1b9..2974174f107a6e79a02c2312bd209c9c130215fd 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -5,7 +5,6 @@ namespace Illuminate\Validation\Concerns; use Countable; use DateTime; use DateTimeInterface; -use DateTimeZone; use Egulias\EmailValidator\EmailValidator; use Egulias\EmailValidator\Validation\DNSCheckValidation; use Egulias\EmailValidator\Validation\MultipleValidationWithAnd; @@ -15,7 +14,6 @@ use Egulias\EmailValidator\Validation\SpoofCheckValidation; use Exception; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Exists; @@ -24,7 +22,6 @@ use Illuminate\Validation\ValidationData; use InvalidArgumentException; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Throwable; trait ValidatesAttributes { @@ -44,6 +41,68 @@ trait ValidatesAttributes return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); } + /** + * Validate that an attribute was "accepted" when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateAcceptedIf($attribute, $value, $parameters) + { + $acceptable = ['yes', 'on', '1', 1, true, 'true']; + + $this->requireParameterCount(2, $parameters, 'accepted_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + return true; + } + + /** + * Validate that an attribute was "declined". + * + * This validation rule implies the attribute is "required". + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateDeclined($attribute, $value) + { + $acceptable = ['no', 'off', '0', 0, false, 'false']; + + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + /** + * Validate that an attribute was "declined" when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateDeclinedIf($attribute, $value, $parameters) + { + $acceptable = ['no', 'off', '0', 0, false, 'false']; + + $this->requireParameterCount(2, $parameters, 'declined_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value) && in_array($value, $acceptable, true); + } + + return true; + } + /** * Validate that an attribute is an active URL. * @@ -59,7 +118,7 @@ trait ValidatesAttributes if ($url = parse_url($value, PHP_URL_HOST)) { try { - return count(dns_get_record($url, DNS_A | DNS_AAAA)) > 0; + return count(dns_get_record($url.'.', DNS_A | DNS_AAAA)) > 0; } catch (Exception $e) { return false; } @@ -187,19 +246,9 @@ trait ValidatesAttributes */ protected function getDateTimestamp($value) { - if ($value instanceof DateTimeInterface) { - return $value->getTimestamp(); - } + $date = is_null($value) ? null : $this->getDateTime($value); - if ($this->isTestingRelativeDateTime($value)) { - $date = $this->getDateTime($value); - - if (! is_null($date)) { - return $date->getTimestamp(); - } - } - - return strtotime($value); + return $date ? $date->getTimestamp() : null; } /** @@ -216,7 +265,11 @@ trait ValidatesAttributes $firstDate = $this->getDateTimeWithOptionalFormat($format, $first); if (! $secondDate = $this->getDateTimeWithOptionalFormat($format, $second)) { - $secondDate = $this->getDateTimeWithOptionalFormat($format, $this->getValue($second)); + if (is_null($second = $this->getValue($second))) { + return true; + } + + $secondDate = $this->getDateTimeWithOptionalFormat($format, $second); } return ($firstDate && $secondDate) && ($this->compare($firstDate, $secondDate, $operator)); @@ -247,29 +300,12 @@ trait ValidatesAttributes protected function getDateTime($value) { try { - if ($this->isTestingRelativeDateTime($value)) { - return Date::parse($value); - } - - return date_create($value) ?: null; + return @Date::parse($value) ?: null; } catch (Exception $e) { // } } - /** - * Check if the given value should be adjusted to Carbon::getTestNow(). - * - * @param mixed $value - * @return bool - */ - protected function isTestingRelativeDateTime($value) - { - return Carbon::hasTestNow() && is_string($value) && ( - $value === 'now' || Carbon::hasRelativeKeywords($value) - ); - } - /** * Validate that an attribute contains only alphabetic characters. * @@ -319,11 +355,43 @@ trait ValidatesAttributes * * @param string $attribute * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateArray($attribute, $value, $parameters = []) + { + if (! is_array($value)) { + return false; + } + + if (empty($parameters)) { + return true; + } + + return empty(array_diff_key($value, array_fill_keys($parameters, ''))); + } + + /** + * Validate that an array has all of the given keys. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters * @return bool */ - public function validateArray($attribute, $value) + public function validateRequiredArrayKeys($attribute, $value, $parameters) { - return is_array($value); + if (! is_array($value)) { + return false; + } + + foreach ($parameters as $param) { + if (! Arr::exists($value, $param)) { + return false; + } + } + + return true; } /** @@ -369,6 +437,28 @@ trait ValidatesAttributes return $this->validateSame($attribute, $value, [$attribute.'_confirmation']); } + /** + * Validate that the password of the currently authenticated user matches the given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + protected function validateCurrentPassword($attribute, $value, $parameters) + { + $auth = $this->container->make('auth'); + $hasher = $this->container->make('hash'); + + $guard = $auth->guard(Arr::first($parameters)); + + if ($guard->guest()) { + return false; + } + + return $hasher->check($value, $guard->user()->getAuthPassword()); + } + /** * Validate that an attribute is a valid date. * @@ -382,7 +472,11 @@ trait ValidatesAttributes return true; } - if ((! is_string($value) && ! is_numeric($value)) || strtotime($value) === false) { + try { + if ((! is_string($value) && ! is_numeric($value)) || strtotime($value) === false) { + return false; + } + } catch (Exception $e) { return false; } @@ -407,11 +501,15 @@ trait ValidatesAttributes return false; } - $format = $parameters[0]; + foreach ($parameters as $format) { + $date = DateTime::createFromFormat('!'.$format, $value); - $date = DateTime::createFromFormat('!'.$format, $value); + if ($date && $date->format($format) == $value) { + return true; + } + } - return $date && $date->format($format) == $value; + return false; } /** @@ -442,14 +540,12 @@ trait ValidatesAttributes $this->requireParameterCount(1, $parameters, 'different'); foreach ($parameters as $parameter) { - if (! Arr::has($this->data, $parameter)) { - return false; - } + if (Arr::has($this->data, $parameter)) { + $other = Arr::get($this->data, $parameter); - $other = Arr::get($this->data, $parameter); - - if ($value === $other) { - return false; + if ($value === $other) { + return false; + } } } @@ -579,7 +675,7 @@ trait ValidatesAttributes return empty(preg_grep('/^'.preg_quote($value, '/').'$/iu', $data)); } - return ! in_array($value, array_values($data)); + return ! in_array($value, array_values($data), in_array('strict', $parameters)); } /** @@ -640,19 +736,23 @@ trait ValidatesAttributes ->unique() ->map(function ($validation) { if ($validation === 'rfc') { - return new RFCValidation(); + return new RFCValidation; } elseif ($validation === 'strict') { - return new NoRFCWarningsValidation(); + return new NoRFCWarningsValidation; } elseif ($validation === 'dns') { - return new DNSCheckValidation(); + return new DNSCheckValidation; } elseif ($validation === 'spoof') { - return new SpoofCheckValidation(); + return new SpoofCheckValidation; } elseif ($validation === 'filter') { - return new FilterEmailValidation(); + return new FilterEmailValidation; + } elseif ($validation === 'filter_unicode') { + return FilterEmailValidation::unicode(); + } elseif (is_string($validation) && class_exists($validation)) { + return $this->container->make($validation); } }) ->values() - ->all() ?: [new RFCValidation()]; + ->all() ?: [new RFCValidation]; return (new EmailValidator)->isValid($value, new MultipleValidationWithAnd($validations)); } @@ -695,7 +795,7 @@ trait ValidatesAttributes */ protected function getExistCount($connection, $table, $column, $value, $parameters) { - $verifier = $this->getPresenceVerifierFor($connection); + $verifier = $this->getPresenceVerifier($connection); $extra = $this->getExtraConditions( array_values(array_slice($parameters, 2)) @@ -724,17 +824,17 @@ trait ValidatesAttributes { $this->requireParameterCount(1, $parameters, 'unique'); - [$connection, $table] = $this->parseTable($parameters[0]); + [$connection, $table, $idColumn] = $this->parseTable($parameters[0]); // The second parameter position holds the name of the column that needs to // be verified as unique. If this parameter isn't specified we will just // assume that this column to be verified shares the attribute's name. $column = $this->getQueryColumn($parameters, $attribute); - [$idColumn, $id] = [null, null]; + $id = null; if (isset($parameters[2])) { - [$idColumn, $id] = $this->getUniqueIds($parameters); + [$idColumn, $id] = $this->getUniqueIds($idColumn, $parameters); if (! is_null($id)) { $id = stripslashes($id); @@ -744,7 +844,7 @@ trait ValidatesAttributes // The presence verifier is responsible for counting rows within this store // mechanism which might be a relational database or any other permanent // data store like Redis, etc. We will use it to determine uniqueness. - $verifier = $this->getPresenceVerifierFor($connection); + $verifier = $this->getPresenceVerifier($connection); $extra = $this->getUniqueExtra($parameters); @@ -760,12 +860,13 @@ trait ValidatesAttributes /** * Get the excluded ID column and value for the unique rule. * + * @param string|null $idColumn * @param array $parameters * @return array */ - protected function getUniqueIds($parameters) + protected function getUniqueIds($idColumn, $parameters) { - $idColumn = $parameters[3] ?? 'id'; + $idColumn = $idColumn ?? $parameters[3] ?? 'id'; return [$idColumn, $this->prepareUniqueId($parameters[2])]; } @@ -822,11 +923,16 @@ trait ValidatesAttributes $model = new $table; $table = $model->getTable(); - $connection = $connection ?? $model->getConnectionName(); + + if (Str::contains($table, '.') && Str::startsWith($table, $connection)) { + $connection = null; + } + + $idColumn = $model->getKeyName(); } - return [$connection, $table]; + return [$connection, $table, $idColumn ?? null]; } /** @@ -1081,7 +1187,7 @@ trait ValidatesAttributes } /** - * Validate that the values of an attribute is in another attribute. + * Validate that the values of an attribute are in another attribute. * * @param string $attribute * @param mixed $value @@ -1151,6 +1257,18 @@ trait ValidatesAttributes return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; } + /** + * Validate that an attribute is a valid MAC address. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function validateMacAddress($attribute, $value) + { + return filter_var($value, FILTER_VALIDATE_MAC) !== false; + } + /** * Validate the attribute is a valid JSON string. * @@ -1254,7 +1372,7 @@ trait ValidatesAttributes } $phpExtensions = [ - 'php', 'php3', 'php4', 'php5', 'phtml', + 'php', 'php3', 'php4', 'php5', 'phtml', 'phar', ]; return ($value instanceof UploadedFile) @@ -1277,6 +1395,29 @@ trait ValidatesAttributes return $this->getSize($attribute, $value) >= $parameters[0]; } + /** + * Validate the value of an attribute is a multiple of a given value. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateMultipleOf($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'multiple_of'); + + if (! $this->validateNumeric($attribute, $value) || ! $this->validateNumeric($attribute, $parameters[0])) { + return false; + } + + if ((float) $parameters[0] === 0.0) { + return false; + } + + return bcmod($value, $parameters[0], 16) === '0.0000000000000000'; + } + /** * "Indicate" validation should pass if value is null. * @@ -1315,7 +1456,7 @@ trait ValidatesAttributes } /** - * Validate that the current logged in user's password matches the given value. + * Validate that the password of the currently authenticated user matches the given value. * * @param string $attribute * @param mixed $value @@ -1324,16 +1465,7 @@ trait ValidatesAttributes */ protected function validatePassword($attribute, $value, $parameters) { - $auth = $this->container->make('auth'); - $hasher = $this->container->make('hash'); - - $guard = $auth->guard(Arr::first($parameters)); - - if ($guard->guest()) { - return false; - } - - return $hasher->check($value, $guard->user()->getAuthPassword()); + return $this->validateCurrentPassword($attribute, $value, $parameters); } /** @@ -1420,15 +1552,97 @@ trait ValidatesAttributes { $this->requireParameterCount(2, $parameters, 'required_if'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + if (! Arr::has($this->data, $parameters[0])) { + return true; + } + + [$values, $other] = $this->parseDependentRuleParameters($parameters); - if (in_array($other, $values)) { + if (in_array($other, $values, is_bool($other) || is_null($other))) { return $this->validateRequired($attribute, $value); } return true; } + /** + * Validate that an attribute does not exist. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibited($attribute, $value) + { + return false; + } + + /** + * Validate that an attribute does not exist when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other) || is_null($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist unless another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that other attributes do not exist when this attribute exists. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibits($attribute, $value, $parameters) + { + return ! Arr::hasAny($this->data, $parameters); + } + + /** + * Indicate that an attribute is excluded. + * + * @return bool + */ + public function validateExclude() + { + return false; + } + /** * Indicate that an attribute should be excluded when another attribute has a given value. * @@ -1441,9 +1655,13 @@ trait ValidatesAttributes { $this->requireParameterCount(2, $parameters, 'exclude_if'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + if (! Arr::has($this->data, $parameters[0])) { + return true; + } + + [$values, $other] = $this->parseDependentRuleParameters($parameters); - return ! in_array($other, $values); + return ! in_array($other, $values, is_bool($other) || is_null($other)); } /** @@ -1458,9 +1676,49 @@ trait ValidatesAttributes { $this->requireParameterCount(2, $parameters, 'exclude_unless'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + return in_array($other, $values, is_bool($other) || is_null($other)); + } + + /** + * Validate that an attribute exists when another attribute does not have a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateRequiredUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'required_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other) || is_null($other))) { + return $this->validateRequired($attribute, $value); + } - return in_array($other, $values); + return true; + } + + /** + * Indicate that an attribute should be excluded when another attribute is missing. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeWithout($attribute, $value, $parameters) + { + $this->requireParameterCount(1, $parameters, 'exclude_without'); + + if ($this->anyFailingRequired($parameters)) { + return false; + } + + return true; } /** @@ -1469,19 +1727,34 @@ trait ValidatesAttributes * @param array $parameters * @return array */ - protected function prepareValuesAndOther($parameters) + public function parseDependentRuleParameters($parameters) { $other = Arr::get($this->data, $parameters[0]); $values = array_slice($parameters, 1); - if (is_bool($other)) { + if ($this->shouldConvertToBoolean($parameters[0]) || is_bool($other)) { $values = $this->convertValuesToBoolean($values); } + if (is_null($other)) { + $values = $this->convertValuesToNull($values); + } + return [$values, $other]; } + /** + * Check if parameter should be converted to boolean. + * + * @param string $parameter + * @return bool + */ + protected function shouldConvertToBoolean($parameter) + { + return in_array('boolean', Arr::get($this->rules, $parameter, [])); + } + /** * Convert the given values to boolean if they are string "true" / "false". * @@ -1502,24 +1775,16 @@ trait ValidatesAttributes } /** - * Validate that an attribute exists when another attribute does not have a given value. + * Convert the given values to null if they are string "null". * - * @param string $attribute - * @param mixed $value - * @param mixed $parameters - * @return bool + * @param array $values + * @return array */ - public function validateRequiredUnless($attribute, $value, $parameters) + protected function convertValuesToNull($values) { - $this->requireParameterCount(2, $parameters, 'required_unless'); - - [$values, $other] = $this->prepareValuesAndOther($parameters); - - if (! in_array($other, $values)) { - return $this->validateRequired($attribute, $value); - } - - return true; + return array_map(function ($value) { + return Str::lower($value) === 'null' ? null : $value; + }, $values); } /** @@ -1540,7 +1805,7 @@ trait ValidatesAttributes } /** - * Validate that an attribute exists when all other attributes exists. + * Validate that an attribute exists when all other attributes exist. * * @param string $attribute * @param mixed $value @@ -1715,15 +1980,7 @@ trait ValidatesAttributes */ public function validateTimezone($attribute, $value) { - try { - new DateTimeZone($value); - } catch (Exception $e) { - return false; - } catch (Throwable $e) { - return false; - } - - return true; + return in_array($value, timezone_identifiers_list(), true); } /** @@ -1745,7 +2002,7 @@ trait ValidatesAttributes * (c) Fabien Potencier <fabien@symfony.com> http://symfony.com */ $pattern = '~^ - (aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|things|thismessage|tip|tn3270|tool|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s):// # protocol + (aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s):// # protocol (((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)? # basic auth ( ([\pL\pN\pS\-\_\.])+(\.?([\pL\pN]|xn\-\-[\pL\pN-]+)+\.?) # a domain name @@ -1800,7 +2057,7 @@ trait ValidatesAttributes return $value->getSize() / 1024; } - return mb_strlen($value); + return mb_strlen($value ?? ''); } /** @@ -1852,7 +2109,7 @@ trait ValidatesAttributes * @param array $parameters * @return array */ - protected function parseNamedParameters($parameters) + public function parseNamedParameters($parameters) { return array_reduce($parameters, function ($result, $item) { [$key, $value] = array_pad(explode('=', $item, 2), 2, null); @@ -1897,7 +2154,6 @@ trait ValidatesAttributes * * @param string $attribute * @param string $rule - * * @return void */ protected function shouldBeNumeric($attribute, $rule) diff --git a/src/Illuminate/Validation/ConditionalRules.php b/src/Illuminate/Validation/ConditionalRules.php new file mode 100644 index 0000000000000000000000000000000000000000..d52455a5d78bfdef850d3c9f7966c356baace2d9 --- /dev/null +++ b/src/Illuminate/Validation/ConditionalRules.php @@ -0,0 +1,77 @@ +<?php + +namespace Illuminate\Validation; + +use Illuminate\Support\Fluent; + +class ConditionalRules +{ + /** + * The boolean condition indicating if the rules should be added to the attribute. + * + * @var callable|bool + */ + protected $condition; + + /** + * The rules to be added to the attribute. + * + * @var array|string + */ + protected $rules; + + /** + * The rules to be added to the attribute if the condition fails. + * + * @var array|string + */ + protected $defaultRules; + + /** + * Create a new conditional rules instance. + * + * @param callable|bool $condition + * @param array|string $rules + * @param array|string $defaultRules + * @return void + */ + public function __construct($condition, $rules, $defaultRules = []) + { + $this->condition = $condition; + $this->rules = $rules; + $this->defaultRules = $defaultRules; + } + + /** + * Determine if the conditional rules should be added. + * + * @param array $data + * @return bool + */ + public function passes(array $data = []) + { + return is_callable($this->condition) + ? call_user_func($this->condition, new Fluent($data)) + : $this->condition; + } + + /** + * Get the rules. + * + * @return array + */ + public function rules() + { + return is_string($this->rules) ? explode('|', $this->rules) : $this->rules; + } + + /** + * Get the default rules. + * + * @return array + */ + public function defaultRules() + { + return is_string($this->defaultRules) ? explode('|', $this->defaultRules) : $this->defaultRules; + } +} diff --git a/src/Illuminate/Validation/DatabasePresenceVerifier.php b/src/Illuminate/Validation/DatabasePresenceVerifier.php index 156536486156d9437fe810c1731f7e722c553bbe..b5255e3457cfe634a1e1d6e797e1335aec5024da 100755 --- a/src/Illuminate/Validation/DatabasePresenceVerifier.php +++ b/src/Illuminate/Validation/DatabasePresenceVerifier.php @@ -6,7 +6,7 @@ use Closure; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Support\Str; -class DatabasePresenceVerifier implements PresenceVerifierInterface +class DatabasePresenceVerifier implements DatabasePresenceVerifierInterface { /** * The database connection instance. @@ -120,7 +120,7 @@ class DatabasePresenceVerifier implements PresenceVerifierInterface * @param string $table * @return \Illuminate\Database\Query\Builder */ - public function table($table) + protected function table($table) { return $this->db->connection($this->connection)->table($table)->useWritePdo(); } diff --git a/src/Illuminate/Validation/DatabasePresenceVerifierInterface.php b/src/Illuminate/Validation/DatabasePresenceVerifierInterface.php new file mode 100755 index 0000000000000000000000000000000000000000..4b70ee0b935f0cc754b7fa169c5db690b3ac7581 --- /dev/null +++ b/src/Illuminate/Validation/DatabasePresenceVerifierInterface.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Validation; + +interface DatabasePresenceVerifierInterface extends PresenceVerifierInterface +{ + /** + * Set the connection to be used. + * + * @param string $connection + * @return void + */ + public function setConnection($connection); +} diff --git a/src/Illuminate/Validation/Factory.php b/src/Illuminate/Validation/Factory.php index cd2ff7066450685971e612db46e650cd39ef725d..3d9d190355590eafee983970eec3990d857dccdc 100755 --- a/src/Illuminate/Validation/Factory.php +++ b/src/Illuminate/Validation/Factory.php @@ -66,6 +66,13 @@ class Factory implements FactoryContract */ protected $fallbackMessages = []; + /** + * Indicates that unvalidated array keys should be excluded, even if the parent array was validated. + * + * @var bool + */ + protected $excludeUnvalidatedArrayKeys; + /** * The Validator resolver instance. * @@ -115,6 +122,8 @@ class Factory implements FactoryContract $validator->setContainer($this->container); } + $validator->excludeUnvalidatedArrayKeys = $this->excludeUnvalidatedArrayKeys; + $this->addExtensions($validator); return $validator; @@ -239,6 +248,16 @@ class Factory implements FactoryContract $this->replacers[$rule] = $replacer; } + /** + * Indicate that unvalidated array keys should be excluded, even if the parent array was validated. + * + * @return void + */ + public function excludeUnvalidatedArrayKeys() + { + $this->excludeUnvalidatedArrayKeys = true; + } + /** * Set the Validator instance resolver. * @@ -280,4 +299,27 @@ class Factory implements FactoryContract { $this->verifier = $presenceVerifier; } + + /** + * Get the container instance used by the validation factory. + * + * @return \Illuminate\Contracts\Container\Container + */ + public function getContainer() + { + return $this->container; + } + + /** + * Set the container instance used by the validation factory. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } } diff --git a/src/Illuminate/Validation/NotPwnedVerifier.php b/src/Illuminate/Validation/NotPwnedVerifier.php new file mode 100644 index 0000000000000000000000000000000000000000..f1c2f1de67f30bd7124ecf5a2b4c7faafe739715 --- /dev/null +++ b/src/Illuminate/Validation/NotPwnedVerifier.php @@ -0,0 +1,104 @@ +<?php + +namespace Illuminate\Validation; + +use Exception; +use Illuminate\Contracts\Validation\UncompromisedVerifier; +use Illuminate\Support\Str; + +class NotPwnedVerifier implements UncompromisedVerifier +{ + /** + * The HTTP factory instance. + * + * @var \Illuminate\Http\Client\Factory + */ + protected $factory; + + /** + * The number of seconds the request can run before timing out. + * + * @var int + */ + protected $timeout; + + /** + * Create a new uncompromised verifier. + * + * @param \Illuminate\Http\Client\Factory $factory + * @param int|null $timeout + * @return void + */ + public function __construct($factory, $timeout = null) + { + $this->factory = $factory; + $this->timeout = $timeout ?? 30; + } + + /** + * Verify that the given data has not been compromised in public breaches. + * + * @param array $data + * @return bool + */ + public function verify($data) + { + $value = $data['value']; + $threshold = $data['threshold']; + + if (empty($value = (string) $value)) { + return false; + } + + [$hash, $hashPrefix] = $this->getHash($value); + + return ! $this->search($hashPrefix) + ->contains(function ($line) use ($hash, $hashPrefix, $threshold) { + [$hashSuffix, $count] = explode(':', $line); + + return $hashPrefix.$hashSuffix == $hash && $count > $threshold; + }); + } + + /** + * Get the hash and its first 5 chars. + * + * @param string $value + * @return array + */ + protected function getHash($value) + { + $hash = strtoupper(sha1((string) $value)); + + $hashPrefix = substr($hash, 0, 5); + + return [$hash, $hashPrefix]; + } + + /** + * Search by the given hash prefix and returns all occurrences of leaked passwords. + * + * @param string $hashPrefix + * @return \Illuminate\Support\Collection + */ + protected function search($hashPrefix) + { + try { + $response = $this->factory->withHeaders([ + 'Add-Padding' => true, + ])->timeout($this->timeout)->get( + 'https://api.pwnedpasswords.com/range/'.$hashPrefix + ); + } catch (Exception $e) { + report($e); + } + + $body = (isset($response) && $response->successful()) + ? $response->body() + : ''; + + return Str::of($body)->trim()->explode("\n")->filter(function ($line) { + return Str::contains($line, ':'); + }); + } +} diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index 9f9ebc82d6febed3884e1f81fb380acfecdf6a4c..fba3e7c7dd44e613456fb068b20e73633dd2571f 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -15,6 +15,19 @@ class Rule { use Macroable; + /** + * Create a new conditional rule set. + * + * @param callable|bool $condition + * @param array|string $rules + * @param array|string $defaultRules + * @return \Illuminate\Validation\ConditionalRules + */ + public static function when($condition, $rules, $defaultRules = []) + { + return new ConditionalRules($condition, $rules, $defaultRules); + } + /** * Get a dimensions constraint builder instance. * @@ -27,7 +40,7 @@ class Rule } /** - * Get a exists constraint builder instance. + * Get an exists constraint builder instance. * * @param string $table * @param string $column diff --git a/src/Illuminate/Validation/Rules/DatabaseRule.php b/src/Illuminate/Validation/Rules/DatabaseRule.php index e9b110ba917fbca7881bea970831bfb8c477d967..7789008483fabc6d87c12cf829c2aa6b5d438ed3 100644 --- a/src/Illuminate/Validation/Rules/DatabaseRule.php +++ b/src/Illuminate/Validation/Rules/DatabaseRule.php @@ -63,7 +63,15 @@ trait DatabaseRule } if (is_subclass_of($table, Model::class)) { - return (new $table)->getTable(); + $model = new $table; + + if (Str::contains($model->getTable(), '.')) { + return $table; + } + + return implode('.', array_map(function (string $part) { + return trim($part, '.'); + }, array_filter([$model->getConnectionName(), $model->getTable()]))); } return $table; @@ -73,7 +81,7 @@ trait DatabaseRule * Set a "where" constraint on the query. * * @param \Closure|string $column - * @param array|string|null $value + * @param array|string|int|null $value * @return $this */ public function where($column, $value = null) @@ -86,6 +94,10 @@ trait DatabaseRule return $this->using($column); } + if (is_null($value)) { + return $this->whereNull($column); + } + $this->wheres[] = compact('column', 'value'); return $this; diff --git a/src/Illuminate/Validation/Rules/Dimensions.php b/src/Illuminate/Validation/Rules/Dimensions.php index e2326c7732b1b5261f594a02213fc6f06a24d037..624cbcb8caf7e8073ca43188d1182abcb9f56bbe 100644 --- a/src/Illuminate/Validation/Rules/Dimensions.php +++ b/src/Illuminate/Validation/Rules/Dimensions.php @@ -2,8 +2,12 @@ namespace Illuminate\Validation\Rules; +use Illuminate\Support\Traits\Conditionable; + class Dimensions { + use Conditionable; + /** * The constraints for the dimensions rule. * diff --git a/src/Illuminate/Validation/Rules/Enum.php b/src/Illuminate/Validation/Rules/Enum.php new file mode 100644 index 0000000000000000000000000000000000000000..df8f9821b65b9c36009ee02f28daa0a115fa1a27 --- /dev/null +++ b/src/Illuminate/Validation/Rules/Enum.php @@ -0,0 +1,61 @@ +<?php + +namespace Illuminate\Validation\Rules; + +use Illuminate\Contracts\Validation\Rule; +use TypeError; + +class Enum implements Rule +{ + /** + * The type of the enum. + * + * @var string + */ + protected $type; + + /** + * Create a new rule instance. + * + * @param string $type + * @return void + */ + public function __construct($type) + { + $this->type = $type; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + if (is_null($value) || ! function_exists('enum_exists') || ! enum_exists($this->type) || ! method_exists($this->type, 'tryFrom')) { + return false; + } + + try { + return ! is_null($this->type::tryFrom($value)); + } catch (TypeError $e) { + return false; + } + } + + /** + * Get the validation error message. + * + * @return array + */ + public function message() + { + $message = trans('validation.enum'); + + return $message === 'validation.enum' + ? ['The selected :attribute is invalid.'] + : $message; + } +} diff --git a/src/Illuminate/Validation/Rules/Exists.php b/src/Illuminate/Validation/Rules/Exists.php index 72c37860096406a98e3a6fde54cf1bed06a54c39..374dcf3a328d65535e97f0798cf711dd18149653 100644 --- a/src/Illuminate/Validation/Rules/Exists.php +++ b/src/Illuminate/Validation/Rules/Exists.php @@ -2,9 +2,24 @@ namespace Illuminate\Validation\Rules; +use Illuminate\Support\Traits\Conditionable; + class Exists { - use DatabaseRule; + use Conditionable, DatabaseRule; + + /** + * Ignore soft deleted models during the existence check. + * + * @param string $deletedAtColumn + * @return $this + */ + public function withoutTrashed($deletedAtColumn = 'deleted_at') + { + $this->whereNull($deletedAtColumn); + + return $this; + } /** * Convert the rule to a validation string. diff --git a/src/Illuminate/Validation/Rules/Password.php b/src/Illuminate/Validation/Rules/Password.php new file mode 100644 index 0000000000000000000000000000000000000000..676dda6f49f358fc0ccbd78ae11a79a907550c71 --- /dev/null +++ b/src/Illuminate/Validation/Rules/Password.php @@ -0,0 +1,364 @@ +<?php + +namespace Illuminate\Validation\Rules; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Validation\DataAwareRule; +use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\UncompromisedVerifier; +use Illuminate\Contracts\Validation\ValidatorAwareRule; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Traits\Conditionable; +use InvalidArgumentException; + +class Password implements Rule, DataAwareRule, ValidatorAwareRule +{ + use Conditionable; + + /** + * The validator performing the validation. + * + * @var \Illuminate\Contracts\Validation\Validator + */ + protected $validator; + + /** + * The data under validation. + * + * @var array + */ + protected $data; + + /** + * The minimum size of the password. + * + * @var int + */ + protected $min = 8; + + /** + * If the password requires at least one uppercase and one lowercase letter. + * + * @var bool + */ + protected $mixedCase = false; + + /** + * If the password requires at least one letter. + * + * @var bool + */ + protected $letters = false; + + /** + * If the password requires at least one number. + * + * @var bool + */ + protected $numbers = false; + + /** + * If the password requires at least one symbol. + * + * @var bool + */ + protected $symbols = false; + + /** + * If the password should has not been compromised in data leaks. + * + * @var bool + */ + protected $uncompromised = false; + + /** + * The number of times a password can appear in data leaks before being consider compromised. + * + * @var int + */ + protected $compromisedThreshold = 0; + + /** + * Additional validation rules that should be merged into the default rules during validation. + * + * @var array + */ + protected $customRules = []; + + /** + * The failure messages, if any. + * + * @var array + */ + protected $messages = []; + + /** + * The callback that will generate the "default" version of the password rule. + * + * @var string|array|callable|null + */ + public static $defaultCallback; + + /** + * Create a new rule instance. + * + * @param int $min + * @return void + */ + public function __construct($min) + { + $this->min = max((int) $min, 1); + } + + /** + * Set the default callback to be used for determining a password's default rules. + * + * If no arguments are passed, the default password rule configuration will be returned. + * + * @param static|callable|null $callback + * @return static|null + */ + public static function defaults($callback = null) + { + if (is_null($callback)) { + return static::default(); + } + + if (! is_callable($callback) && ! $callback instanceof static) { + throw new InvalidArgumentException('The given callback should be callable or an instance of '.static::class); + } + + static::$defaultCallback = $callback; + } + + /** + * Get the default configuration of the password rule. + * + * @return static + */ + public static function default() + { + $password = is_callable(static::$defaultCallback) + ? call_user_func(static::$defaultCallback) + : static::$defaultCallback; + + return $password instanceof Rule ? $password : static::min(8); + } + + /** + * Get the default configuration of the password rule and mark the field as required. + * + * @return array + */ + public static function required() + { + return ['required', static::default()]; + } + + /** + * Get the default configuration of the password rule and mark the field as sometimes being required. + * + * @return array + */ + public static function sometimes() + { + return ['sometimes', static::default()]; + } + + /** + * Set the performing validator. + * + * @param \Illuminate\Contracts\Validation\Validator $validator + * @return $this + */ + public function setValidator($validator) + { + $this->validator = $validator; + + return $this; + } + + /** + * Set the data under validation. + * + * @param array $data + * @return $this + */ + public function setData($data) + { + $this->data = $data; + + return $this; + } + + /** + * Sets the minimum size of the password. + * + * @param int $size + * @return $this + */ + public static function min($size) + { + return new static($size); + } + + /** + * Ensures the password has not been compromised in data leaks. + * + * @param int $threshold + * @return $this + */ + public function uncompromised($threshold = 0) + { + $this->uncompromised = true; + + $this->compromisedThreshold = $threshold; + + return $this; + } + + /** + * Makes the password require at least one uppercase and one lowercase letter. + * + * @return $this + */ + public function mixedCase() + { + $this->mixedCase = true; + + return $this; + } + + /** + * Makes the password require at least one letter. + * + * @return $this + */ + public function letters() + { + $this->letters = true; + + return $this; + } + + /** + * Makes the password require at least one number. + * + * @return $this + */ + public function numbers() + { + $this->numbers = true; + + return $this; + } + + /** + * Makes the password require at least one symbol. + * + * @return $this + */ + public function symbols() + { + $this->symbols = true; + + return $this; + } + + /** + * Specify additional validation rules that should be merged with the default rules during validation. + * + * @param string|array $rules + * @return $this + */ + public function rules($rules) + { + $this->customRules = Arr::wrap($rules); + + return $this; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + $this->messages = []; + + $validator = Validator::make( + $this->data, + [$attribute => array_merge(['string', 'min:'.$this->min], $this->customRules)], + $this->validator->customMessages, + $this->validator->customAttributes + )->after(function ($validator) use ($attribute, $value) { + if (! is_string($value)) { + return; + } + + $value = (string) $value; + + if ($this->mixedCase && ! preg_match('/(\p{Ll}+.*\p{Lu})|(\p{Lu}+.*\p{Ll})/u', $value)) { + $validator->errors()->add($attribute, 'The :attribute must contain at least one uppercase and one lowercase letter.'); + } + + if ($this->letters && ! preg_match('/\pL/u', $value)) { + $validator->errors()->add($attribute, 'The :attribute must contain at least one letter.'); + } + + if ($this->symbols && ! preg_match('/\p{Z}|\p{S}|\p{P}/u', $value)) { + $validator->errors()->add($attribute, 'The :attribute must contain at least one symbol.'); + } + + if ($this->numbers && ! preg_match('/\pN/u', $value)) { + $validator->errors()->add($attribute, 'The :attribute must contain at least one number.'); + } + }); + + if ($validator->fails()) { + return $this->fail($validator->messages()->all()); + } + + if ($this->uncompromised && ! Container::getInstance()->make(UncompromisedVerifier::class)->verify([ + 'value' => $value, + 'threshold' => $this->compromisedThreshold, + ])) { + return $this->fail( + 'The given :attribute has appeared in a data leak. Please choose a different :attribute.' + ); + } + + return true; + } + + /** + * Get the validation error message. + * + * @return array + */ + public function message() + { + return $this->messages; + } + + /** + * Adds the given failures, and return false. + * + * @param array|string $messages + * @return bool + */ + protected function fail($messages) + { + $messages = collect(Arr::wrap($messages))->map(function ($message) { + return $this->validator->getTranslator()->get($message); + })->all(); + + $this->messages = array_merge($this->messages, $messages); + + return false; + } +} diff --git a/src/Illuminate/Validation/Rules/RequiredIf.php b/src/Illuminate/Validation/Rules/RequiredIf.php index c4a1001d063a5d3aa5fea53d7715661615c0f108..a1ab749157057a095cfa073990932941934d41a2 100644 --- a/src/Illuminate/Validation/Rules/RequiredIf.php +++ b/src/Illuminate/Validation/Rules/RequiredIf.php @@ -2,6 +2,8 @@ namespace Illuminate\Validation\Rules; +use InvalidArgumentException; + class RequiredIf { /** @@ -19,7 +21,11 @@ class RequiredIf */ public function __construct($condition) { - $this->condition = $condition; + if (! is_string($condition)) { + $this->condition = $condition; + } else { + throw new InvalidArgumentException('The provided condition must be a callable or boolean.'); + } } /** diff --git a/src/Illuminate/Validation/Rules/Unique.php b/src/Illuminate/Validation/Rules/Unique.php index 64e910240382ba4272e871816dd1805841c29f2c..02f3d142c84b34e2f6cc9dc23ba78e989ed1494e 100644 --- a/src/Illuminate/Validation/Rules/Unique.php +++ b/src/Illuminate/Validation/Rules/Unique.php @@ -3,10 +3,11 @@ namespace Illuminate\Validation\Rules; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Traits\Conditionable; class Unique { - use DatabaseRule; + use Conditionable, DatabaseRule; /** * The ID that should be ignored. @@ -56,6 +57,19 @@ class Unique return $this; } + /** + * Ignore soft deleted models during the unique check. + * + * @param string $deletedAtColumn + * @return $this + */ + public function withoutTrashed($deletedAtColumn = 'deleted_at') + { + $this->whereNull($deletedAtColumn); + + return $this; + } + /** * Convert the rule to a validation string. * diff --git a/src/Illuminate/Validation/ValidationData.php b/src/Illuminate/Validation/ValidationData.php index 74f552597c1c5467e1f8435b3deb9d3599a9ce64..86da0fd3a17abce8b336342b3cdca68d65bfe279 100644 --- a/src/Illuminate/Validation/ValidationData.php +++ b/src/Illuminate/Validation/ValidationData.php @@ -8,7 +8,7 @@ use Illuminate\Support\Str; class ValidationData { /** - * Initialize and gather data for given attribute. + * Initialize and gather data for the given attribute. * * @param string $attribute * @param array $masterData diff --git a/src/Illuminate/Validation/ValidationRuleParser.php b/src/Illuminate/Validation/ValidationRuleParser.php index ed61c229b9c025c5fa645ceb847ce29d81c86a09..ce499a55a58c6f527d110a94628e1c2a20f35f6e 100644 --- a/src/Illuminate/Validation/ValidationRuleParser.php +++ b/src/Illuminate/Validation/ValidationRuleParser.php @@ -131,7 +131,7 @@ class ValidationRuleParser foreach ($data as $key => $value) { if (Str::startsWith($key, $attribute) || (bool) preg_match('/^'.$pattern.'\z/', $key)) { foreach ((array) $rules as $rule) { - $this->implicitAttributes[$attribute][] = (string) $key; + $this->implicitAttributes[$attribute][] = $key; $results = $this->mergeRules($results, $key, $rule); } @@ -186,57 +186,57 @@ class ValidationRuleParser /** * Extract the rule name and parameters from a rule. * - * @param array|string $rules + * @param array|string $rule * @return array */ - public static function parse($rules) + public static function parse($rule) { - if ($rules instanceof RuleContract) { - return [$rules, []]; + if ($rule instanceof RuleContract) { + return [$rule, []]; } - if (is_array($rules)) { - $rules = static::parseArrayRule($rules); + if (is_array($rule)) { + $rule = static::parseArrayRule($rule); } else { - $rules = static::parseStringRule($rules); + $rule = static::parseStringRule($rule); } - $rules[0] = static::normalizeRule($rules[0]); + $rule[0] = static::normalizeRule($rule[0]); - return $rules; + return $rule; } /** * Parse an array based rule. * - * @param array $rules + * @param array $rule * @return array */ - protected static function parseArrayRule(array $rules) + protected static function parseArrayRule(array $rule) { - return [Str::studly(trim(Arr::get($rules, 0))), array_slice($rules, 1)]; + return [Str::studly(trim(Arr::get($rule, 0, ''))), array_slice($rule, 1)]; } /** * Parse a string based rule. * - * @param string $rules + * @param string $rule * @return array */ - protected static function parseStringRule($rules) + protected static function parseStringRule($rule) { $parameters = []; // The format for specifying validation rules and parameters follows an // easy {rule}:{parameters} formatting convention. For instance the // rule "Max:3" states that the value may only be three letters. - if (strpos($rules, ':') !== false) { - [$rules, $parameter] = explode(':', $rules, 2); + if (strpos($rule, ':') !== false) { + [$rule, $parameter] = explode(':', $rule, 2); - $parameters = static::parseParameters($rules, $parameter); + $parameters = static::parseParameters($rule, $parameter); } - return [Str::studly(trim($rules)), $parameters]; + return [Str::studly(trim($rule)), $parameters]; } /** @@ -274,4 +274,35 @@ class ValidationRuleParser return $rule; } } + + /** + * Expand and conditional rules in the given array of rules. + * + * @param array $rules + * @param array $data + * @return array + */ + public static function filterConditionalRules($rules, array $data = []) + { + return collect($rules)->mapWithKeys(function ($attributeRules, $attribute) use ($data) { + if (! is_array($attributeRules) && + ! $attributeRules instanceof ConditionalRules) { + return [$attribute => $attributeRules]; + } + + if ($attributeRules instanceof ConditionalRules) { + return [$attribute => $attributeRules->passes($data) + ? array_filter($attributeRules->rules()) + : array_filter($attributeRules->defaultRules()), ]; + } + + return [$attribute => collect($attributeRules)->map(function ($rule) use ($data) { + if (! $rule instanceof ConditionalRules) { + return [$rule]; + } + + return $rule->passes($data) ? $rule->rules() : $rule->defaultRules(); + })->filter()->flatten(1)->values()->all()]; + })->all(); + } } diff --git a/src/Illuminate/Validation/ValidationServiceProvider.php b/src/Illuminate/Validation/ValidationServiceProvider.php index ce04447e58e014a5c7db06d04d686ad96f8611ab..936235f9e7bb94cb727be7684e018406ce89c209 100755 --- a/src/Illuminate/Validation/ValidationServiceProvider.php +++ b/src/Illuminate/Validation/ValidationServiceProvider.php @@ -3,6 +3,8 @@ namespace Illuminate\Validation; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Contracts\Validation\UncompromisedVerifier; +use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Support\ServiceProvider; class ValidationServiceProvider extends ServiceProvider implements DeferrableProvider @@ -15,7 +17,7 @@ class ValidationServiceProvider extends ServiceProvider implements DeferrablePro public function register() { $this->registerPresenceVerifier(); - + $this->registerUncompromisedVerifier(); $this->registerValidationFactory(); } @@ -52,6 +54,18 @@ class ValidationServiceProvider extends ServiceProvider implements DeferrablePro }); } + /** + * Register the uncompromised password verifier. + * + * @return void + */ + protected function registerUncompromisedVerifier() + { + $this->app->singleton(UncompromisedVerifier::class, function ($app) { + return new NotPwnedVerifier($app[HttpFactory::class]); + }); + } + /** * Get the services provided by the provider. * diff --git a/src/Illuminate/Validation/Validator.php b/src/Illuminate/Validation/Validator.php index 582075e7cba5bf92bf1855fcebf823873596c50b..9cbc91b2ff3e64b58839a088be9dcb6ae92b7ba4 100755 --- a/src/Illuminate/Validation/Validator.php +++ b/src/Illuminate/Validation/Validator.php @@ -5,14 +5,19 @@ namespace Illuminate\Validation; use BadMethodCallException; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Translation\Translator; +use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ImplicitRule; use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\Validator as ValidatorContract; +use Illuminate\Contracts\Validation\ValidatorAwareRule; use Illuminate\Support\Arr; use Illuminate\Support\Fluent; use Illuminate\Support\MessageBag; use Illuminate\Support\Str; +use Illuminate\Support\ValidatedInput; +use InvalidArgumentException; use RuntimeException; +use stdClass; use Symfony\Component\HttpFoundation\File\UploadedFile; class Validator implements ValidatorContract @@ -146,6 +151,20 @@ class Validator implements ValidatorContract */ public $customValues = []; + /** + * Indicates if the validator should stop on the first rule failure. + * + * @var bool + */ + protected $stopOnFirstFailure = false; + + /** + * Indicates that unvalidated array keys should be excluded, even if the parent array was validated. + * + * @var bool + */ + public $excludeUnvalidatedArrayKeys = false; + /** * All of the custom validator extensions. * @@ -163,53 +182,94 @@ class Validator implements ValidatorContract /** * The validation rules that may be applied to files. * - * @var array + * @var string[] */ protected $fileRules = [ - 'File', 'Image', 'Mimes', 'Mimetypes', 'Min', - 'Max', 'Size', 'Between', 'Dimensions', + 'Between', + 'Dimensions', + 'File', + 'Image', + 'Max', + 'Mimes', + 'Mimetypes', + 'Min', + 'Size', ]; /** * The validation rules that imply the field is required. * - * @var array + * @var string[] */ protected $implicitRules = [ - 'Required', 'Filled', 'RequiredWith', 'RequiredWithAll', 'RequiredWithout', - 'RequiredWithoutAll', 'RequiredIf', 'RequiredUnless', 'Accepted', 'Present', + 'Accepted', + 'AcceptedIf', + 'Declined', + 'DeclinedIf', + 'Filled', + 'Present', + 'Required', + 'RequiredIf', + 'RequiredUnless', + 'RequiredWith', + 'RequiredWithAll', + 'RequiredWithout', + 'RequiredWithoutAll', ]; /** * The validation rules which depend on other fields as parameters. * - * @var array + * @var string[] */ protected $dependentRules = [ - 'RequiredWith', 'RequiredWithAll', 'RequiredWithout', 'RequiredWithoutAll', - 'RequiredIf', 'RequiredUnless', 'Confirmed', 'Same', 'Different', 'Unique', - 'Before', 'After', 'BeforeOrEqual', 'AfterOrEqual', 'Gt', 'Lt', 'Gte', 'Lte', - 'ExcludeIf', 'ExcludeUnless', + 'After', + 'AfterOrEqual', + 'Before', + 'BeforeOrEqual', + 'Confirmed', + 'Different', + 'ExcludeIf', + 'ExcludeUnless', + 'ExcludeWithout', + 'Gt', + 'Gte', + 'Lt', + 'Lte', + 'AcceptedIf', + 'DeclinedIf', + 'RequiredIf', + 'RequiredUnless', + 'RequiredWith', + 'RequiredWithAll', + 'RequiredWithout', + 'RequiredWithoutAll', + 'Prohibited', + 'ProhibitedIf', + 'ProhibitedUnless', + 'Prohibits', + 'Same', + 'Unique', ]; /** * The validation rules that can exclude an attribute. * - * @var array + * @var string[] */ - protected $excludeRules = ['ExcludeIf', 'ExcludeUnless']; + protected $excludeRules = ['Exclude', 'ExcludeIf', 'ExcludeUnless', 'ExcludeWithout']; /** * The size related validation rules. * - * @var array + * @var string[] */ protected $sizeRules = ['Size', 'Between', 'Min', 'Max', 'Gt', 'Lt', 'Gte', 'Lte']; /** * The numeric related validation rules. * - * @var array + * @var string[] */ protected $numericRules = ['Numeric', 'Integer']; @@ -220,6 +280,13 @@ class Validator implements ValidatorContract */ protected $dotPlaceholder; + /** + * The exception to throw upon failure. + * + * @var string + */ + protected $exception = ValidationException::class; + /** * Create a new Validator instance. * @@ -245,7 +312,7 @@ class Validator implements ValidatorContract } /** - * Parse the data array, converting dots to ->. + * Parse the data array, converting dots and asterisks. * * @param array $data * @return array @@ -271,6 +338,40 @@ class Validator implements ValidatorContract return $newData; } + /** + * Replace the placeholders used in data keys. + * + * @param array $data + * @return array + */ + protected function replacePlaceholders($data) + { + $originalData = []; + + foreach ($data as $key => $value) { + $originalData[$this->replacePlaceholderInString($key)] = is_array($value) + ? $this->replacePlaceholders($value) + : $value; + } + + return $originalData; + } + + /** + * Replace the placeholders in the given string. + * + * @param string $value + * @return string + */ + protected function replacePlaceholderInString(string $value) + { + return str_replace( + [$this->dotPlaceholder, '__asterisk__'], + ['.', '*'], + $value + ); + } + /** * Add an after validation callback. * @@ -307,6 +408,10 @@ class Validator implements ValidatorContract continue; } + if ($this->stopOnFirstFailure && $this->messages->isNotEmpty()) { + break; + } + foreach ($rules as $rule) { $this->validateAttribute($attribute, $rule); @@ -364,7 +469,6 @@ class Validator implements ValidatorContract * Remove the given attribute. * * @param string $attribute - * * @return void */ protected function removeAttribute($attribute) @@ -382,13 +486,43 @@ class Validator implements ValidatorContract */ public function validate() { - if ($this->fails()) { - throw new ValidationException($this); - } + throw_if($this->fails(), $this->exception, $this); return $this->validated(); } + /** + * Run the validator's rules against its data. + * + * @param string $errorBag + * @return array + * + * @throws \Illuminate\Validation\ValidationException + */ + public function validateWithBag(string $errorBag) + { + try { + return $this->validate(); + } catch (ValidationException $e) { + $e->errorBag = $errorBag; + + throw $e; + } + } + + /** + * Get a validated input container for the validated input. + * + * @param array|null $keys + * @return \Illuminate\Support\ValidatedInput|array + */ + public function safe(array $keys = null) + { + return is_array($keys) + ? (new ValidatedInput($this->validated()))->only($keys) + : new ValidatedInput($this->validated()); + } + /** * Get the attributes and values that were validated. * @@ -398,15 +532,19 @@ class Validator implements ValidatorContract */ public function validated() { - if ($this->invalid()) { - throw new ValidationException($this); - } + throw_if($this->invalid(), $this->exception, $this); $results = []; - $missingValue = Str::random(10); + $missingValue = new stdClass; + + foreach ($this->getRules() as $key => $rules) { + if ($this->excludeUnvalidatedArrayKeys && + in_array('array', $rules) && + ! empty(preg_grep('/^'.preg_quote($key, '/').'\.+/', array_keys($this->getRules())))) { + continue; + } - foreach (array_keys($this->getRules()) as $key) { $value = data_get($this->getData(), $key, $missingValue); if ($value !== $missingValue) { @@ -414,7 +552,7 @@ class Validator implements ValidatorContract } } - return $results; + return $this->replacePlaceholders($results); } /** @@ -430,16 +568,19 @@ class Validator implements ValidatorContract [$rule, $parameters] = ValidationRuleParser::parse($rule); - if ($rule == '') { + if ($rule === '') { return; } // First we will get the correct keys for the given attribute in case the field is nested in // an array. Then we determine if the given rule accepts other field names as parameters. // If so, we will replace any asterisks found in the parameters with the correct keys. - if (($keys = $this->getExplicitKeys($attribute)) && - $this->dependsOnOtherFields($rule)) { - $parameters = $this->replaceAsterisksInParameters($parameters, $keys); + if ($this->dependsOnOtherFields($rule)) { + $parameters = $this->replaceDotInParameters($parameters); + + if ($keys = $this->getExplicitKeys($attribute)) { + $parameters = $this->replaceAsterisksInParameters($parameters, $keys); + } } $value = $this->getValue($attribute); @@ -514,7 +655,7 @@ class Validator implements ValidatorContract protected function getPrimaryAttribute($attribute) { foreach ($this->implicitAttributes as $unparsed => $parsed) { - if (in_array($attribute, $parsed)) { + if (in_array($attribute, $parsed, true)) { return $unparsed; } } @@ -522,6 +663,20 @@ class Validator implements ValidatorContract return $attribute; } + /** + * Replace each field parameter which has an escaped dot with the dot placeholder. + * + * @param array $parameters + * @param array $keys + * @return array + */ + protected function replaceDotInParameters(array $parameters) + { + return array_map(function ($field) { + return str_replace('\.', $this->dotPlaceholder, $field); + }, $parameters); + } + /** * Replace each field parameter which has asterisks with the given keys. * @@ -644,10 +799,24 @@ class Validator implements ValidatorContract */ protected function validateUsingCustomRule($attribute, $value, $rule) { + $attribute = $this->replacePlaceholderInString($attribute); + + $value = is_array($value) ? $this->replacePlaceholders($value) : $value; + + if ($rule instanceof ValidatorAwareRule) { + $rule->setValidator($this); + } + + if ($rule instanceof DataAwareRule) { + $rule->setData($this->data); + } + if (! $rule->passes($attribute, $value)) { $this->failedRules[$attribute][get_class($rule)] = []; - $messages = $rule->message() ? (array) $rule->message() : [get_class($rule)]; + $messages = $rule->message(); + + $messages = $messages ? (array) $messages : [get_class($rule)]; foreach ($messages as $message) { $this->messages->add($attribute, $this->makeReplacements( @@ -665,12 +834,14 @@ class Validator implements ValidatorContract */ protected function shouldStopValidating($attribute) { + $cleanedAttribute = $this->replacePlaceholderInString($attribute); + if ($this->hasRule($attribute, ['Bail'])) { - return $this->messages->has($attribute); + return $this->messages->has($cleanedAttribute); } - if (isset($this->failedRules[$attribute]) && - array_key_exists('uploaded', $this->failedRules[$attribute])) { + if (isset($this->failedRules[$cleanedAttribute]) && + array_key_exists('uploaded', $this->failedRules[$cleanedAttribute])) { return true; } @@ -678,8 +849,8 @@ class Validator implements ValidatorContract // and that rule already failed then we should stop validation at this point // as now there is no point in calling other rules with this field empty. return $this->hasRule($attribute, $this->implicitRules) && - isset($this->failedRules[$attribute]) && - array_intersect(array_keys($this->failedRules[$attribute]), $this->implicitRules); + isset($this->failedRules[$cleanedAttribute]) && + array_intersect(array_keys($this->failedRules[$cleanedAttribute]), $this->implicitRules); } /** @@ -696,14 +867,20 @@ class Validator implements ValidatorContract $this->passes(); } - $attribute = str_replace('__asterisk__', '*', $attribute); + $attributeWithPlaceholders = $attribute; + + $attribute = str_replace( + [$this->dotPlaceholder, '__asterisk__'], + ['.', '*'], + $attribute + ); if (in_array($rule, $this->excludeRules)) { return $this->excludeAttribute($attribute); } $this->messages->add($attribute, $this->makeReplacements( - $this->getMessage($attribute, $rule), $attribute, $rule, $parameters + $this->getMessage($attributeWithPlaceholders, $rule), $attribute, $rule, $parameters )); $this->failedRules[$attribute][$rule] = $parameters; @@ -945,7 +1122,7 @@ class Validator implements ValidatorContract // of the explicit rules needed for the given data. For example the rule // names.* would get expanded to names.0, names.1, etc. for this data. $response = (new ValidationRuleParser($this->data)) - ->explode($rules); + ->explode(ValidationRuleParser::filterConditionalRules($rules, $this->data)); $this->rules = array_merge_recursive( $this->rules, $response->rules @@ -966,17 +1143,55 @@ class Validator implements ValidatorContract */ public function sometimes($attribute, $rules, callable $callback) { - $payload = new Fluent($this->getData()); + $payload = new Fluent($this->data); - if ($callback($payload)) { - foreach ((array) $attribute as $key) { - $this->addRules([$key => $rules]); + foreach ((array) $attribute as $key) { + $response = (new ValidationRuleParser($this->data))->explode([$key => $rules]); + + $this->implicitAttributes = array_merge($response->implicitAttributes, $this->implicitAttributes); + + foreach ($response->rules as $ruleKey => $ruleValue) { + if ($callback($payload, $this->dataForSometimesIteration($ruleKey, ! Str::endsWith($key, '.*')))) { + $this->addRules([$ruleKey => $ruleValue]); + } } } return $this; } + /** + * Get the data that should be injected into the iteration of a wildcard "sometimes" callback. + * + * @param string $attribute + * @return \Illuminate\Support\Fluent|array|mixed + */ + private function dataForSometimesIteration(string $attribute, $removeLastSegmentOfAttribute) + { + $lastSegmentOfAttribute = strrchr($attribute, '.'); + + $attribute = $lastSegmentOfAttribute && $removeLastSegmentOfAttribute + ? Str::replaceLast($lastSegmentOfAttribute, '', $attribute) + : $attribute; + + return is_array($data = data_get($this->data, $attribute)) + ? new Fluent($data) + : $data; + } + + /** + * Instruct the validator to stop validating after the first rule failure. + * + * @param bool $stopOnFirstFailure + * @return $this + */ + public function stopOnFirstFailure($stopOnFirstFailure = true) + { + $this->stopOnFirstFailure = $stopOnFirstFailure; + + return $this; + } + /** * Register an array of custom validator extensions. * @@ -1133,7 +1348,7 @@ class Validator implements ValidatorContract } /** - * Set the callback that used to format an implicit attribute.. + * Set the callback that used to format an implicit attribute. * * @param callable|null $formatter * @return $this @@ -1185,43 +1400,54 @@ class Validator implements ValidatorContract /** * Get the Presence Verifier implementation. * + * @param string|null $connection * @return \Illuminate\Validation\PresenceVerifierInterface * * @throws \RuntimeException */ - public function getPresenceVerifier() + public function getPresenceVerifier($connection = null) { if (! isset($this->presenceVerifier)) { throw new RuntimeException('Presence verifier has not been set.'); } + if ($this->presenceVerifier instanceof DatabasePresenceVerifierInterface) { + $this->presenceVerifier->setConnection($connection); + } + return $this->presenceVerifier; } /** - * Get the Presence Verifier implementation. - * - * @param string $connection - * @return \Illuminate\Validation\PresenceVerifierInterface + * Set the Presence Verifier implementation. * - * @throws \RuntimeException + * @param \Illuminate\Validation\PresenceVerifierInterface $presenceVerifier + * @return void */ - public function getPresenceVerifierFor($connection) + public function setPresenceVerifier(PresenceVerifierInterface $presenceVerifier) { - return tap($this->getPresenceVerifier(), function ($verifier) use ($connection) { - $verifier->setConnection($connection); - }); + $this->presenceVerifier = $presenceVerifier; } /** - * Set the Presence Verifier implementation. + * Set the exception to throw upon failed validation. * - * @param \Illuminate\Validation\PresenceVerifierInterface $presenceVerifier - * @return void + * @param string $exception + * @return $this + * + * @throws \InvalidArgumentException */ - public function setPresenceVerifier(PresenceVerifierInterface $presenceVerifier) + public function setException($exception) { - $this->presenceVerifier = $presenceVerifier; + if (! is_a($exception, ValidationException::class, true)) { + throw new InvalidArgumentException( + sprintf('Exception [%s] is invalid. It must extend [%s].', $exception, ValidationException::class) + ); + } + + $this->exception = $exception; + + return $this; } /** diff --git a/src/Illuminate/Validation/composer.json b/src/Illuminate/Validation/composer.json index a879fd6817a5f7451b5ea6a2bb546855cbe0776c..f4a0babc731fe95ffbd300de26f5fa4caed0c5ad 100755 --- a/src/Illuminate/Validation/composer.json +++ b/src/Illuminate/Validation/composer.json @@ -14,14 +14,17 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", "egulias/email-validator": "^2.1.10", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/support": "^6.0", - "illuminate/translation": "^6.0", - "symfony/http-foundation": "^4.3.4" + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0", + "illuminate/translation": "^8.0", + "symfony/http-foundation": "^5.4", + "symfony/mime": "^5.4" }, "autoload": { "psr-4": { @@ -30,11 +33,12 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "suggest": { - "illuminate/database": "Required to use the database presence verifier (^6.0)." + "ext-bcmath": "Required to use the multiple_of validation rule.", + "illuminate/database": "Required to use the database presence verifier (^8.0)." }, "config": { "sort-packages": true diff --git a/src/Illuminate/View/AnonymousComponent.php b/src/Illuminate/View/AnonymousComponent.php new file mode 100644 index 0000000000000000000000000000000000000000..2fb21e1afc4aadc94253ff24d9d6246effa65efa --- /dev/null +++ b/src/Illuminate/View/AnonymousComponent.php @@ -0,0 +1,60 @@ +<?php + +namespace Illuminate\View; + +class AnonymousComponent extends Component +{ + /** + * The component view. + * + * @var string + */ + protected $view; + + /** + * The component data. + * + * @var array + */ + protected $data = []; + + /** + * Create a new anonymous component instance. + * + * @param string $view + * @param array $data + * @return void + */ + public function __construct($view, $data) + { + $this->view = $view; + $this->data = $data; + } + + /** + * Get the view / view contents that represent the component. + * + * @return string + */ + public function render() + { + return $this->view; + } + + /** + * Get the data that should be supplied to the view. + * + * @return array + */ + public function data() + { + $this->attributes = $this->attributes ?: $this->newAttributeBag(); + + return array_merge( + optional($this->data['attributes'] ?? null)->getAttributes() ?: [], + $this->attributes->getAttributes(), + $this->data, + ['attributes' => $this->attributes] + ); + } +} diff --git a/src/Illuminate/View/AppendableAttributeValue.php b/src/Illuminate/View/AppendableAttributeValue.php new file mode 100644 index 0000000000000000000000000000000000000000..f275801e4e1de9b923c416c6b9dfb23b0aa26ead --- /dev/null +++ b/src/Illuminate/View/AppendableAttributeValue.php @@ -0,0 +1,34 @@ +<?php + +namespace Illuminate\View; + +class AppendableAttributeValue +{ + /** + * The attribute value. + * + * @var mixed + */ + public $value; + + /** + * Create a new appendable attribute value. + * + * @param mixed $value + * @return void + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * Get the string value. + * + * @return string + */ + public function __toString() + { + return (string) $this->value; + } +} diff --git a/src/Illuminate/View/Compilers/BladeCompiler.php b/src/Illuminate/View/Compilers/BladeCompiler.php index 5eb431fa97b007eed0ed6e1a0fd2fe95051757e7..fd3f91a271a37f030fff7d6a1f56e0355667c488 100644 --- a/src/Illuminate/View/Compilers/BladeCompiler.php +++ b/src/Illuminate/View/Compilers/BladeCompiler.php @@ -2,13 +2,20 @@ namespace Illuminate\View\Compilers; +use Illuminate\Container\Container; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Contracts\View\View; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Support\Traits\ReflectsClosures; +use Illuminate\View\Component; use InvalidArgumentException; class BladeCompiler extends Compiler implements CompilerInterface { use Concerns\CompilesAuthorizations, + Concerns\CompilesClasses, Concerns\CompilesComments, Concerns\CompilesComponents, Concerns\CompilesConditionals, @@ -18,11 +25,13 @@ class BladeCompiler extends Compiler implements CompilerInterface Concerns\CompilesIncludes, Concerns\CompilesInjections, Concerns\CompilesJson, + Concerns\CompilesJs, Concerns\CompilesLayouts, Concerns\CompilesLoops, Concerns\CompilesRawPhp, Concerns\CompilesStacks, - Concerns\CompilesTranslations; + Concerns\CompilesTranslations, + ReflectsClosures; /** * All of the registered extensions. @@ -45,6 +54,13 @@ class BladeCompiler extends Compiler implements CompilerInterface */ protected $conditions = []; + /** + * All of the registered precompilers. + * + * @var array + */ + protected $precompilers = []; + /** * The file currently being compiled. * @@ -55,10 +71,10 @@ class BladeCompiler extends Compiler implements CompilerInterface /** * All of the available compiler functions. * - * @var array + * @var string[] */ protected $compilers = [ - 'Comments', + // 'Comments', 'Extensions', 'Statements', 'Echos', @@ -67,21 +83,21 @@ class BladeCompiler extends Compiler implements CompilerInterface /** * Array of opening and closing tags for raw echos. * - * @var array + * @var string[] */ protected $rawTags = ['{!!', '!!}']; /** * Array of opening and closing tags for regular echos. * - * @var array + * @var string[] */ protected $contentTags = ['{{', '}}']; /** * Array of opening and closing tags for escaped echos. * - * @var array + * @var string[] */ protected $escapedTags = ['{{{', '}}}']; @@ -93,19 +109,40 @@ class BladeCompiler extends Compiler implements CompilerInterface protected $echoFormat = 'e(%s)'; /** - * Array of footer lines to be added to template. + * Array of footer lines to be added to the template. * * @var array */ protected $footer = []; /** - * Array to temporary store the raw blocks found in the template. + * Array to temporarily store the raw blocks found in the template. * * @var array */ protected $rawBlocks = []; + /** + * The array of class component aliases and their class names. + * + * @var array + */ + protected $classComponentAliases = []; + + /** + * The array of class component namespaces to autoload from. + * + * @var array + */ + protected $classComponentNamespaces = []; + + /** + * Indicates if component tags should be compiled. + * + * @var bool + */ + protected $compilesComponentTags = true; + /** * Compile the view at the given path. * @@ -125,9 +162,11 @@ class BladeCompiler extends Compiler implements CompilerInterface $contents = $this->appendFilePath($contents); } - $this->files->put( - $this->getCompiledPath($this->getPath()), $contents + $this->ensureCompiledDirectoryExists( + $compiledPath = $this->getCompiledPath($this->getPath()) ); + + $this->files->put($compiledPath, $contents); } } @@ -194,7 +233,16 @@ class BladeCompiler extends Compiler implements CompilerInterface { [$this->footer, $result] = [[], '']; - $value = $this->storeUncompiledBlocks($value); + // First we will compile the Blade component tags. This is a precompile style + // step which compiles the component Blade tags into @component directives + // that may be used by Blade. Then we should call any other precompilers. + $value = $this->compileComponentTags( + $this->compileComments($this->storeUncompiledBlocks($value)) + ); + + foreach ($this->precompilers as $precompiler) { + $value = call_user_func($precompiler, $value); + } // Here we will loop through all of the tokens returned by the Zend lexer and // parse each one into the corresponding valid PHP. We will then have this @@ -214,7 +262,74 @@ class BladeCompiler extends Compiler implements CompilerInterface $result = $this->addFooters($result); } - return $result; + if (! empty($this->echoHandlers)) { + $result = $this->addBladeCompilerVariable($result); + } + + return str_replace( + ['##BEGIN-COMPONENT-CLASS##', '##END-COMPONENT-CLASS##'], + '', + $result); + } + + /** + * Evaluate and render a Blade string to HTML. + * + * @param string $string + * @param array $data + * @param bool $deleteCachedView + * @return string + */ + public static function render($string, $data = [], $deleteCachedView = false) + { + $component = new class($string) extends Component + { + protected $template; + + public function __construct($template) + { + $this->template = $template; + } + + public function render() + { + return $this->template; + } + }; + + $view = Container::getInstance() + ->make(ViewFactory::class) + ->make($component->resolveView(), $data); + + return tap($view->render(), function () use ($view, $deleteCachedView) { + if ($deleteCachedView) { + unlink($view->getPath()); + } + }); + } + + /** + * Render a component instance to HTML. + * + * @param \Illuminate\View\Component $component + * @return string + */ + public static function renderComponent(Component $component) + { + $data = $component->data(); + + $view = value($component->resolveView(), $data); + + if ($view instanceof View) { + return $view->with($data)->render(); + } elseif ($view instanceof Htmlable) { + return $view->toHtml(); + } else { + return Container::getInstance() + ->make(ViewFactory::class) + ->make($view, $data) + ->render(); + } } /** @@ -275,6 +390,23 @@ class BladeCompiler extends Compiler implements CompilerInterface ); } + /** + * Compile the component tags. + * + * @param string $value + * @return string + */ + protected function compileComponentTags($value) + { + if (! $this->compilesComponentTags) { + return $value; + } + + return (new ComponentTagCompiler( + $this->classComponentAliases, $this->classComponentNamespaces, $this + ))->compile($value); + } + /** * Replace the raw placeholders with the original code stored in the raw blocks. * @@ -293,7 +425,7 @@ class BladeCompiler extends Compiler implements CompilerInterface } /** - * Get a placeholder to temporary mark the position of raw blocks. + * Get a placeholder to temporarily mark the position of raw blocks. * * @param int|string $replace * @return string @@ -311,8 +443,8 @@ class BladeCompiler extends Compiler implements CompilerInterface */ protected function addFooters($result) { - return ltrim($result, PHP_EOL) - .PHP_EOL.implode(PHP_EOL, array_reverse($this->footer)); + return ltrim($result, "\n") + ."\n".implode("\n", array_reverse($this->footer)); } /** @@ -392,6 +524,8 @@ class BladeCompiler extends Compiler implements CompilerInterface */ protected function callCustomDirective($name, $value) { + $value = $value ?? ''; + if (Str::startsWith($value, '(') && Str::endsWith($value, ')')) { $value = Str::substr($value, 1, -1); } @@ -481,6 +615,85 @@ class BladeCompiler extends Compiler implements CompilerInterface return call_user_func($this->conditions[$name], ...$parameters); } + /** + * Register a class-based component alias directive. + * + * @param string $class + * @param string|null $alias + * @param string $prefix + * @return void + */ + public function component($class, $alias = null, $prefix = '') + { + if (! is_null($alias) && Str::contains($alias, '\\')) { + [$class, $alias] = [$alias, $class]; + } + + if (is_null($alias)) { + $alias = Str::contains($class, '\\View\\Components\\') + ? collect(explode('\\', Str::after($class, '\\View\\Components\\')))->map(function ($segment) { + return Str::kebab($segment); + })->implode(':') + : Str::kebab(class_basename($class)); + } + + if (! empty($prefix)) { + $alias = $prefix.'-'.$alias; + } + + $this->classComponentAliases[$alias] = $class; + } + + /** + * Register an array of class-based components. + * + * @param array $components + * @param string $prefix + * @return void + */ + public function components(array $components, $prefix = '') + { + foreach ($components as $key => $value) { + if (is_numeric($key)) { + $this->component($value, null, $prefix); + } else { + $this->component($key, $value, $prefix); + } + } + } + + /** + * Get the registered class component aliases. + * + * @return array + */ + public function getClassComponentAliases() + { + return $this->classComponentAliases; + } + + /** + * Register a class-based component namespace. + * + * @param string $namespace + * @param string $prefix + * @return void + */ + public function componentNamespace($namespace, $prefix) + { + $this->classComponentNamespaces[$prefix] = $namespace; + } + + /** + * Get the registered class component namespaces. + * + * @return array + */ + public function getClassComponentNamespaces() + { + return $this->classComponentNamespaces; + } + /** * Register a component alias directive. * @@ -488,7 +701,7 @@ class BladeCompiler extends Compiler implements CompilerInterface * @param string|null $alias * @return void */ - public function component($path, $alias = null) + public function aliasComponent($path, $alias = null) { $alias = $alias ?: Arr::last(explode('.', $path)); @@ -511,6 +724,18 @@ class BladeCompiler extends Compiler implements CompilerInterface * @return void */ public function include($path, $alias = null) + { + $this->aliasInclude($path, $alias); + } + + /** + * Register an include alias directive. + * + * @param string $path + * @param string|null $alias + * @return void + */ + public function aliasInclude($path, $alias = null) { $alias = $alias ?: Arr::last(explode('.', $path)); @@ -549,6 +774,17 @@ class BladeCompiler extends Compiler implements CompilerInterface return $this->customDirectives; } + /** + * Register a new precompiler. + * + * @param callable $precompiler + * @return void + */ + public function precompiler(callable $precompiler) + { + $this->precompilers[] = $precompiler; + } + /** * Set the echo format to be used by the compiler. * @@ -579,4 +815,14 @@ class BladeCompiler extends Compiler implements CompilerInterface { $this->setEchoFormat('e(%s, false)'); } + + /** + * Indicate that component tags should not be compiled. + * + * @return void + */ + public function withoutComponentTags() + { + $this->compilesComponentTags = false; + } } diff --git a/src/Illuminate/View/Compilers/Compiler.php b/src/Illuminate/View/Compilers/Compiler.php index 08648ad17b878faf449f6505610916f0c210a632..e14c8524cc44c9f8eaf389cf6e0140cce03b133c 100755 --- a/src/Illuminate/View/Compilers/Compiler.php +++ b/src/Illuminate/View/Compilers/Compiler.php @@ -48,7 +48,7 @@ abstract class Compiler */ public function getCompiledPath($path) { - return $this->cachePath.'/'.sha1($path).'.php'; + return $this->cachePath.'/'.sha1('v2'.$path).'.php'; } /** @@ -71,4 +71,17 @@ abstract class Compiler return $this->files->lastModified($path) >= $this->files->lastModified($compiled); } + + /** + * Create the compiled file directory if necessary. + * + * @param string $path + * @return void + */ + protected function ensureCompiledDirectoryExists($path) + { + if (! $this->files->exists(dirname($path))) { + $this->files->makeDirectory(dirname($path), 0777, true, true); + } + } } diff --git a/src/Illuminate/View/Compilers/ComponentTagCompiler.php b/src/Illuminate/View/Compilers/ComponentTagCompiler.php new file mode 100644 index 0000000000000000000000000000000000000000..469bd783664b49ce11b0b9c2ce30942350306c31 --- /dev/null +++ b/src/Illuminate/View/Compilers/ComponentTagCompiler.php @@ -0,0 +1,617 @@ +<?php + +namespace Illuminate\View\Compilers; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\View\Factory; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Str; +use Illuminate\View\AnonymousComponent; +use Illuminate\View\DynamicComponent; +use Illuminate\View\ViewFinderInterface; +use InvalidArgumentException; +use ReflectionClass; + +/** + * @author Spatie bvba <info@spatie.be> + * @author Taylor Otwell <taylor@laravel.com> + */ +class ComponentTagCompiler +{ + /** + * The Blade compiler instance. + * + * @var \Illuminate\View\Compilers\BladeCompiler + */ + protected $blade; + + /** + * The component class aliases. + * + * @var array + */ + protected $aliases = []; + + /** + * The component class namespaces. + * + * @var array + */ + protected $namespaces = []; + + /** + * The "bind:" attributes that have been compiled for the current component. + * + * @var array + */ + protected $boundAttributes = []; + + /** + * Create a new component tag compiler. + * + * @param array $aliases + * @param array $namespaces + * @param \Illuminate\View\Compilers\BladeCompiler|null $blade + * @return void + */ + public function __construct(array $aliases = [], array $namespaces = [], ?BladeCompiler $blade = null) + { + $this->aliases = $aliases; + $this->namespaces = $namespaces; + + $this->blade = $blade ?: new BladeCompiler(new Filesystem, sys_get_temp_dir()); + } + + /** + * Compile the component and slot tags within the given string. + * + * @param string $value + * @return string + */ + public function compile(string $value) + { + $value = $this->compileSlots($value); + + return $this->compileTags($value); + } + + /** + * Compile the tags within the given string. + * + * @param string $value + * @return string + * + * @throws \InvalidArgumentException + */ + public function compileTags(string $value) + { + $value = $this->compileSelfClosingTags($value); + $value = $this->compileOpeningTags($value); + $value = $this->compileClosingTags($value); + + return $value; + } + + /** + * Compile the opening tags within the given string. + * + * @param string $value + * @return string + * + * @throws \InvalidArgumentException + */ + protected function compileOpeningTags(string $value) + { + $pattern = "/ + < + \s* + x[-\:]([\w\-\:\.]*) + (?<attributes> + (?: + \s+ + (?: + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (?<![\/=\-]) + > + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString($matches[1], $attributes); + }, $value); + } + + /** + * Compile the self-closing tags within the given string. + * + * @param string $value + * @return string + * + * @throws \InvalidArgumentException + */ + protected function compileSelfClosingTags(string $value) + { + $pattern = "/ + < + \s* + x[-\:]([\w\-\:\.]*) + \s* + (?<attributes> + (?: + \s+ + (?: + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + \/> + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString($matches[1], $attributes)."\n@endComponentClass##END-COMPONENT-CLASS##"; + }, $value); + } + + /** + * Compile the Blade component string for the given component and attributes. + * + * @param string $component + * @param array $attributes + * @return string + * + * @throws \InvalidArgumentException + */ + protected function componentString(string $component, array $attributes) + { + $class = $this->componentClass($component); + + [$data, $attributes] = $this->partitionDataAndAttributes($class, $attributes); + + $data = $data->mapWithKeys(function ($value, $key) { + return [Str::camel($key) => $value]; + }); + + // If the component doesn't exists as a class we'll assume it's a class-less + // component and pass the component as a view parameter to the data so it + // can be accessed within the component and we can render out the view. + if (! class_exists($class)) { + $parameters = [ + 'view' => "'$class'", + 'data' => '['.$this->attributesToString($data->all(), $escapeBound = false).']', + ]; + + $class = AnonymousComponent::class; + } else { + $parameters = $data->all(); + } + + return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [".$this->attributesToString($parameters, $escapeBound = false).']) +<?php $component->withAttributes(['.$this->attributesToString($attributes->all(), $escapeAttributes = $class !== DynamicComponent::class).']); ?>'; + } + + /** + * Get the component class for a given component alias. + * + * @param string $component + * @return string + * + * @throws \InvalidArgumentException + */ + public function componentClass(string $component) + { + $viewFactory = Container::getInstance()->make(Factory::class); + + if (isset($this->aliases[$component])) { + if (class_exists($alias = $this->aliases[$component])) { + return $alias; + } + + if ($viewFactory->exists($alias)) { + return $alias; + } + + throw new InvalidArgumentException( + "Unable to locate class or view [{$alias}] for component [{$component}]." + ); + } + + if ($class = $this->findClassByComponent($component)) { + return $class; + } + + if (class_exists($class = $this->guessClassName($component))) { + return $class; + } + + if ($viewFactory->exists($view = $this->guessViewName($component))) { + return $view; + } + + if ($viewFactory->exists($view = $this->guessViewName($component).'.index')) { + return $view; + } + + throw new InvalidArgumentException( + "Unable to locate a class or view for component [{$component}]." + ); + } + + /** + * Find the class for the given component using the registered namespaces. + * + * @param string $component + * @return string|null + */ + public function findClassByComponent(string $component) + { + $segments = explode('::', $component); + + $prefix = $segments[0]; + + if (! isset($this->namespaces[$prefix]) || ! isset($segments[1])) { + return; + } + + if (class_exists($class = $this->namespaces[$prefix].'\\'.$this->formatClassName($segments[1]))) { + return $class; + } + } + + /** + * Guess the class name for the given component. + * + * @param string $component + * @return string + */ + public function guessClassName(string $component) + { + $namespace = Container::getInstance() + ->make(Application::class) + ->getNamespace(); + + $class = $this->formatClassName($component); + + return $namespace.'View\\Components\\'.$class; + } + + /** + * Format the class name for the given component. + * + * @param string $component + * @return string + */ + public function formatClassName(string $component) + { + $componentPieces = array_map(function ($componentPiece) { + return ucfirst(Str::camel($componentPiece)); + }, explode('.', $component)); + + return implode('\\', $componentPieces); + } + + /** + * Guess the view name for the given component. + * + * @param string $name + * @return string + */ + public function guessViewName($name) + { + $prefix = 'components.'; + + $delimiter = ViewFinderInterface::HINT_PATH_DELIMITER; + + if (Str::contains($name, $delimiter)) { + return Str::replaceFirst($delimiter, $delimiter.$prefix, $name); + } + + return $prefix.$name; + } + + /** + * Partition the data and extra attributes from the given array of attributes. + * + * @param string $class + * @param array $attributes + * @return array + */ + public function partitionDataAndAttributes($class, array $attributes) + { + // If the class doesn't exists, we'll assume it's a class-less component and + // return all of the attributes as both data and attributes since we have + // now way to partition them. The user can exclude attributes manually. + if (! class_exists($class)) { + return [collect($attributes), collect($attributes)]; + } + + $constructor = (new ReflectionClass($class))->getConstructor(); + + $parameterNames = $constructor + ? collect($constructor->getParameters())->map->getName()->all() + : []; + + return collect($attributes)->partition(function ($value, $key) use ($parameterNames) { + return in_array(Str::camel($key), $parameterNames); + })->all(); + } + + /** + * Compile the closing tags within the given string. + * + * @param string $value + * @return string + */ + protected function compileClosingTags(string $value) + { + return preg_replace("/<\/\s*x[-\:][\w\-\:\.]*\s*>/", ' @endComponentClass##END-COMPONENT-CLASS##', $value); + } + + /** + * Compile the slot tags within the given string. + * + * @param string $value + * @return string + */ + public function compileSlots(string $value) + { + $pattern = "/ + < + \s* + x[\-\:]slot + \s+ + (:?)name=(?<name>(\"[^\"]+\"|\\\'[^\\\']+\\\'|[^\s>]+)) + (?<attributes> + (?: + \s+ + (?: + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + [\w\-:.@]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (?<![\/=\-]) + > + /x"; + + $value = preg_replace_callback($pattern, function ($matches) { + $name = $this->stripQuotes($matches['name']); + + if ($matches[1] !== ':') { + $name = "'{$name}'"; + } + + $this->boundAttributes = []; + + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return " @slot({$name}, null, [".$this->attributesToString($attributes).']) '; + }, $value); + + return preg_replace('/<\/\s*x[\-\:]slot[^>]*>/', ' @endslot', $value); + } + + /** + * Get an array of attributes from the given attribute string. + * + * @param string $attributeString + * @return array + */ + protected function getAttributesFromAttributeString(string $attributeString) + { + $attributeString = $this->parseAttributeBag($attributeString); + + $attributeString = $this->parseBindAttributes($attributeString); + + $pattern = '/ + (?<attribute>[\w\-:.@]+) + ( + = + (?<value> + ( + \"[^\"]+\" + | + \\\'[^\\\']+\\\' + | + [^\s>]+ + ) + ) + )? + /x'; + + if (! preg_match_all($pattern, $attributeString, $matches, PREG_SET_ORDER)) { + return []; + } + + return collect($matches)->mapWithKeys(function ($match) { + $attribute = $match['attribute']; + $value = $match['value'] ?? null; + + if (is_null($value)) { + $value = 'true'; + + $attribute = Str::start($attribute, 'bind:'); + } + + $value = $this->stripQuotes($value); + + if (Str::startsWith($attribute, 'bind:')) { + $attribute = Str::after($attribute, 'bind:'); + + $this->boundAttributes[$attribute] = true; + } else { + $value = "'".$this->compileAttributeEchos($value)."'"; + } + + if (Str::startsWith($attribute, '::')) { + $attribute = substr($attribute, 1); + } + + return [$attribute => $value]; + })->toArray(); + } + + /** + * Parse the attribute bag in a given attribute string into its fully-qualified syntax. + * + * @param string $attributeString + * @return string + */ + protected function parseAttributeBag(string $attributeString) + { + $pattern = "/ + (?:^|\s+) # start of the string or whitespace between attributes + \{\{\s*(\\\$attributes(?:[^}]+?(?<!\s))?)\s*\}\} # exact match of attributes variable being echoed + /x"; + + return preg_replace($pattern, ' :attributes="$1"', $attributeString); + } + + /** + * Parse the "bind" attributes in a given attribute string into their fully-qualified syntax. + * + * @param string $attributeString + * @return string + */ + protected function parseBindAttributes(string $attributeString) + { + $pattern = "/ + (?:^|\s+) # start of the string or whitespace between attributes + :(?!:) # attribute needs to start with a single colon + ([\w\-:.@]+) # match the actual attribute name + = # only match attributes that have a value + /xm"; + + return preg_replace($pattern, ' bind:$1=', $attributeString); + } + + /** + * Compile any Blade echo statements that are present in the attribute string. + * + * These echo statements need to be converted to string concatenation statements. + * + * @param string $attributeString + * @return string + */ + protected function compileAttributeEchos(string $attributeString) + { + $value = $this->blade->compileEchos($attributeString); + + $value = $this->escapeSingleQuotesOutsideOfPhpBlocks($value); + + $value = str_replace('<?php echo ', '\'.', $value); + $value = str_replace('; ?>', '.\'', $value); + + return $value; + } + + /** + * Escape the single quotes in the given string that are outside of PHP blocks. + * + * @param string $value + * @return string + */ + protected function escapeSingleQuotesOutsideOfPhpBlocks(string $value) + { + return collect(token_get_all($value))->map(function ($token) { + if (! is_array($token)) { + return $token; + } + + return $token[0] === T_INLINE_HTML + ? str_replace("'", "\\'", $token[1]) + : $token[1]; + })->implode(''); + } + + /** + * Convert an array of attributes to a string. + * + * @param array $attributes + * @param bool $escapeBound + * @return string + */ + protected function attributesToString(array $attributes, $escapeBound = true) + { + return collect($attributes) + ->map(function (string $value, string $attribute) use ($escapeBound) { + return $escapeBound && isset($this->boundAttributes[$attribute]) && $value !== 'true' && ! is_numeric($value) + ? "'{$attribute}' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute({$value})" + : "'{$attribute}' => {$value}"; + }) + ->implode(','); + } + + /** + * Strip any quotes from the given string. + * + * @param string $value + * @return string + */ + public function stripQuotes(string $value) + { + return Str::startsWith($value, ['"', '\'']) + ? substr($value, 1, -1) + : $value; + } +} diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesClasses.php b/src/Illuminate/View/Compilers/Concerns/CompilesClasses.php new file mode 100644 index 0000000000000000000000000000000000000000..b3dbbcd2a0e52ccc4966100e23980d7def56ecc9 --- /dev/null +++ b/src/Illuminate/View/Compilers/Concerns/CompilesClasses.php @@ -0,0 +1,19 @@ +<?php + +namespace Illuminate\View\Compilers\Concerns; + +trait CompilesClasses +{ + /** + * Compile the conditional class statement into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileClass($expression) + { + $expression = is_null($expression) ? '([])' : $expression; + + return "class=\"<?php echo \Illuminate\Support\Arr::toCssClasses{$expression} ?>\""; + } +} diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php b/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php index 1ed3b9c22cd0994a6e25d1e43ddc72f762ac426e..db4f0e88abf650b39029dfeeadacbd8fee110808 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php @@ -2,8 +2,19 @@ namespace Illuminate\View\Compilers\Concerns; +use Illuminate\Contracts\Support\CanBeEscapedWhenCastToString; +use Illuminate\Support\Str; +use Illuminate\View\ComponentAttributeBag; + trait CompilesComponents { + /** + * The component name hash stack. + * + * @var array + */ + protected static $componentHashStack = []; + /** * Compile the component statements into valid PHP. * @@ -12,9 +23,54 @@ trait CompilesComponents */ protected function compileComponent($expression) { + [$component, $alias, $data] = strpos($expression, ',') !== false + ? array_map('trim', explode(',', trim($expression, '()'), 3)) + ['', '', ''] + : [trim($expression, '()'), '', '']; + + $component = trim($component, '\'"'); + + $hash = static::newComponentHash($component); + + if (Str::contains($component, ['::class', '\\'])) { + return static::compileClassComponentOpening($component, $alias, $data, $hash); + } + return "<?php \$__env->startComponent{$expression}; ?>"; } + /** + * Get a new component hash for a component name. + * + * @param string $component + * @return string + */ + public static function newComponentHash(string $component) + { + static::$componentHashStack[] = $hash = sha1($component); + + return $hash; + } + + /** + * Compile a class component opening. + * + * @param string $component + * @param string $alias + * @param string $data + * @param string $hash + * @return string + */ + public static function compileClassComponentOpening(string $component, string $alias, string $data, string $hash) + { + return implode("\n", [ + '<?php if (isset($component)) { $__componentOriginal'.$hash.' = $component; } ?>', + '<?php $component = $__env->getContainer()->make('.Str::finish($component, '::class').', '.($data ?: '[]').'); ?>', + '<?php $component->withName('.$alias.'); ?>', + '<?php if ($component->shouldRender()): ?>', + '<?php $__env->startComponent($component->resolveView(), $component->data()); ?>', + ]); + } + /** * Compile the end-component statements into valid PHP. * @@ -25,6 +81,24 @@ trait CompilesComponents return '<?php echo $__env->renderComponent(); ?>'; } + /** + * Compile the end-component statements into valid PHP. + * + * @return string + */ + public function compileEndComponentClass() + { + $hash = array_pop(static::$componentHashStack); + + return $this->compileEndComponent()."\n".implode("\n", [ + '<?php endif; ?>', + '<?php if (isset($__componentOriginal'.$hash.')): ?>', + '<?php $component = $__componentOriginal'.$hash.'; ?>', + '<?php unset($__componentOriginal'.$hash.'); ?>', + '<?php endif; ?>', + ]); + } + /** * Compile the slot statements into valid PHP. * @@ -66,4 +140,55 @@ trait CompilesComponents { return $this->compileEndComponent(); } + + /** + * Compile the prop statement into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileProps($expression) + { + return "<?php \$attributes = \$attributes->exceptProps{$expression}; ?> +<?php foreach (array_filter({$expression}, 'is_string', ARRAY_FILTER_USE_KEY) as \$__key => \$__value) { + \$\$__key = \$\$__key ?? \$__value; +} ?> +<?php \$__defined_vars = get_defined_vars(); ?> +<?php foreach (\$attributes as \$__key => \$__value) { + if (array_key_exists(\$__key, \$__defined_vars)) unset(\$\$__key); +} ?> +<?php unset(\$__defined_vars); ?>"; + } + + /** + * Compile the aware statement into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileAware($expression) + { + return "<?php foreach ({$expression} as \$__key => \$__value) { + \$__consumeVariable = is_string(\$__key) ? \$__key : \$__value; + \$\$__consumeVariable = is_string(\$__key) ? \$__env->getConsumableComponentData(\$__key, \$__value) : \$__env->getConsumableComponentData(\$__value); +} ?>"; + } + + /** + * Sanitize the given component attribute value. + * + * @param mixed $value + * @return mixed + */ + public static function sanitizeComponentAttribute($value) + { + if (is_object($value) && $value instanceof CanBeEscapedWhenCastToString) { + return $value->escapeWhenCastingToString(); + } + + return is_string($value) || + (is_object($value) && ! $value instanceof ComponentAttributeBag && method_exists($value, '__toString')) + ? e($value) + : $value; + } } diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesConditionals.php b/src/Illuminate/View/Compilers/Concerns/CompilesConditionals.php index d592ef1771f0b7178684a1f21763139a397dcc40..6bae1e1cba4a852292548caf9971144f918a1c07 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesConditionals.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesConditionals.php @@ -2,10 +2,12 @@ namespace Illuminate\View\Compilers\Concerns; +use Illuminate\Support\Str; + trait CompilesConditionals { /** - * Identifier for the first case in switch statement. + * Identifier for the first case in the switch statement. * * @var bool */ @@ -47,6 +49,47 @@ trait CompilesConditionals return '<?php endif; ?>'; } + /** + * Compile the env statements into valid PHP. + * + * @param string $environments + * @return string + */ + protected function compileEnv($environments) + { + return "<?php if(app()->environment{$environments}): ?>"; + } + + /** + * Compile the end-env statements into valid PHP. + * + * @return string + */ + protected function compileEndEnv() + { + return '<?php endif; ?>'; + } + + /** + * Compile the production statements into valid PHP. + * + * @return string + */ + protected function compileProduction() + { + return "<?php if(app()->environment('production')): ?>"; + } + + /** + * Compile the end-production statements into valid PHP. + * + * @return string + */ + protected function compileEndProduction() + { + return '<?php endif; ?>'; + } + /** * Compile the if-guest statements into valid PHP. * @@ -94,6 +137,17 @@ trait CompilesConditionals return "<?php if (! empty(trim(\$__env->yieldContent{$expression}))): ?>"; } + /** + * Compile the section-missing statements into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileSectionMissing($expression) + { + return "<?php if (empty(trim(\$__env->yieldContent{$expression}))): ?>"; + } + /** * Compile the if statements into valid PHP. * @@ -227,4 +281,27 @@ trait CompilesConditionals { return '<?php endswitch; ?>'; } + + /** + * Compile a once block into valid PHP. + * + * @param string|null $id + * @return string + */ + protected function compileOnce($id = null) + { + $id = $id ? $this->stripParentheses($id) : "'".(string) Str::uuid()."'"; + + return '<?php if (! $__env->hasRenderedOnce('.$id.')): $__env->markAsRenderedOnce('.$id.'); ?>'; + } + + /** + * Compile an end-once block into valid PHP. + * + * @return string + */ + public function compileEndOnce() + { + return '<?php endif; ?>'; + } } diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesEchos.php b/src/Illuminate/View/Compilers/Concerns/CompilesEchos.php index 86f352e21d1e04da7f05ec50e8d75089e56bfa0e..5924a0ac31fe12ce99157536c8c9ec490c50ebb1 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesEchos.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesEchos.php @@ -2,15 +2,41 @@ namespace Illuminate\View\Compilers\Concerns; +use Closure; +use Illuminate\Support\Str; + trait CompilesEchos { + /** + * Custom rendering callbacks for stringable objects. + * + * @var array + */ + protected $echoHandlers = []; + + /** + * Add a handler to be executed before echoing a given class. + * + * @param string|callable $class + * @param callable|null $handler + * @return void + */ + public function stringable($class, $handler = null) + { + if ($class instanceof Closure) { + [$class, $handler] = [$this->firstClosureParameterType($class), $class]; + } + + $this->echoHandlers[$class] = $handler; + } + /** * Compile Blade echos into valid PHP. * * @param string $value * @return string */ - protected function compileEchos($value) + public function compileEchos($value) { foreach ($this->getEchoMethods() as $method) { $value = $this->$method($value); @@ -46,7 +72,9 @@ trait CompilesEchos $callback = function ($matches) { $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3]; - return $matches[1] ? substr($matches[0], 1) : "<?php echo {$matches[2]}; ?>{$whitespace}"; + return $matches[1] + ? substr($matches[0], 1) + : "<?php echo {$this->wrapInEchoHandler($matches[2])}; ?>{$whitespace}"; }; return preg_replace_callback($pattern, $callback, $value); @@ -65,7 +93,7 @@ trait CompilesEchos $callback = function ($matches) { $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3]; - $wrapped = sprintf($this->echoFormat, $matches[2]); + $wrapped = sprintf($this->echoFormat, $this->wrapInEchoHandler($matches[2])); return $matches[1] ? substr($matches[0], 1) : "<?php echo {$wrapped}; ?>{$whitespace}"; }; @@ -86,9 +114,54 @@ trait CompilesEchos $callback = function ($matches) { $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3]; - return $matches[1] ? $matches[0] : "<?php echo e({$matches[2]}); ?>{$whitespace}"; + return $matches[1] + ? $matches[0] + : "<?php echo e({$this->wrapInEchoHandler($matches[2])}); ?>{$whitespace}"; }; return preg_replace_callback($pattern, $callback, $value); } + + /** + * Add an instance of the blade echo handler to the start of the compiled string. + * + * @param string $result + * @return string + */ + protected function addBladeCompilerVariable($result) + { + return "<?php \$__bladeCompiler = app('blade.compiler'); ?>".$result; + } + + /** + * Wrap the echoable value in an echo handler if applicable. + * + * @param string $value + * @return string + */ + protected function wrapInEchoHandler($value) + { + $value = Str::of($value) + ->trim() + ->when(Str::endsWith($value, ';'), function ($str) { + return $str->beforeLast(';'); + }); + + return empty($this->echoHandlers) ? $value : '$__bladeCompiler->applyEchoHandler('.$value.')'; + } + + /** + * Apply the echo handler for the value if it exists. + * + * @param string $value + * @return string + */ + public function applyEchoHandler($value) + { + if (is_object($value) && isset($this->echoHandlers[get_class($value)])) { + return call_user_func($this->echoHandlers[get_class($value)], $value); + } + + return $value; + } } diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesIncludes.php b/src/Illuminate/View/Compilers/Concerns/CompilesIncludes.php index b80a5b5d21a7faa562090fd64779a3c9b37ae917..aa3d4a6f5b8651f36468a038fa4c6909ca58ee22 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesIncludes.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesIncludes.php @@ -64,7 +64,7 @@ trait CompilesIncludes { $expression = $this->stripParentheses($expression); - return "<?php echo \$__env->renderWhen(! $expression, \Illuminate\Support\Arr::except(get_defined_vars(), ['__data', '__path'])); ?>"; + return "<?php echo \$__env->renderUnless($expression, \Illuminate\Support\Arr::except(get_defined_vars(), ['__data', '__path'])); ?>"; } /** diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesInjections.php b/src/Illuminate/View/Compilers/Concerns/CompilesInjections.php index c295bcd448c52d4dc87471580781117c4eba415e..a0d1ccf5ea31cf87802c70338fafe41cc4fb8650 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesInjections.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesInjections.php @@ -12,12 +12,12 @@ trait CompilesInjections */ protected function compileInject($expression) { - $segments = explode(',', preg_replace("/[\(\)\\\"\']/", '', $expression)); + $segments = explode(',', preg_replace("/[\(\)]/", '', $expression)); - $variable = trim($segments[0]); + $variable = trim($segments[0], " '\""); $service = trim($segments[1]); - return "<?php \${$variable} = app('{$service}'); ?>"; + return "<?php \${$variable} = app({$service}); ?>"; } } diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesJs.php b/src/Illuminate/View/Compilers/Concerns/CompilesJs.php new file mode 100644 index 0000000000000000000000000000000000000000..3104057dfc526c36047c525a6260536f91ec4b15 --- /dev/null +++ b/src/Illuminate/View/Compilers/Concerns/CompilesJs.php @@ -0,0 +1,22 @@ +<?php + +namespace Illuminate\View\Compilers\Concerns; + +use Illuminate\Support\Js; + +trait CompilesJs +{ + /** + * Compile the "@js" directive into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileJs(string $expression) + { + return sprintf( + "<?php echo \%s::from(%s)->toHtml() ?>", + Js::class, $this->stripParentheses($expression) + ); + } +} diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesLayouts.php b/src/Illuminate/View/Compilers/Concerns/CompilesLayouts.php index aaef61747673a8306726d76d0c09a1ad127fad54..6540603d22657ba1b6e9b933f90adda71ecbe9e9 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesLayouts.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesLayouts.php @@ -2,8 +2,6 @@ namespace Illuminate\View\Compilers\Concerns; -use Illuminate\View\Factory as ViewFactory; - trait CompilesLayouts { /** @@ -30,6 +28,23 @@ trait CompilesLayouts return ''; } + /** + * Compile the extends-first statements into valid PHP. + * + * @param string $expression + * @return string + */ + protected function compileExtendsFirst($expression) + { + $expression = $this->stripParentheses($expression); + + $echo = "<?php echo \$__env->first({$expression}, \Illuminate\Support\Arr::except(get_defined_vars(), ['__data', '__path']))->render(); ?>"; + + $this->footer[] = $echo; + + return ''; + } + /** * Compile the section statements into valid PHP. * @@ -50,7 +65,9 @@ trait CompilesLayouts */ protected function compileParent() { - return ViewFactory::parentPlaceholder($this->lastSection ?: ''); + $escapedLastSection = strtr($this->lastSection, ['\\' => '\\\\', "'" => "\\'"]); + + return "<?php echo \Illuminate\View\Factory::parentPlaceholder('{$escapedLastSection}'); ?>"; } /** diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesTranslations.php b/src/Illuminate/View/Compilers/Concerns/CompilesTranslations.php index c47c221c0d935558277c52af31c934c826a1d3c2..7cbafdb93dd8679e0630bf603d42a7938746dd01 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesTranslations.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesTranslations.php @@ -7,7 +7,7 @@ trait CompilesTranslations /** * Compile the lang statements into valid PHP. * - * @param string $expression + * @param string|null $expression * @return string */ protected function compileLang($expression) diff --git a/src/Illuminate/View/Component.php b/src/Illuminate/View/Component.php new file mode 100644 index 0000000000000000000000000000000000000000..8acf9a7f874d4ed51fde131df405a48c4563ffa4 --- /dev/null +++ b/src/Illuminate/View/Component.php @@ -0,0 +1,296 @@ +<?php + +namespace Illuminate\View; + +use Closure; +use Illuminate\Container\Container; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\View\View as ViewContract; +use Illuminate\Support\Str; +use ReflectionClass; +use ReflectionMethod; +use ReflectionProperty; + +abstract class Component +{ + /** + * The cache of public property names, keyed by class. + * + * @var array + */ + protected static $propertyCache = []; + + /** + * The cache of public method names, keyed by class. + * + * @var array + */ + protected static $methodCache = []; + + /** + * The properties / methods that should not be exposed to the component. + * + * @var array + */ + protected $except = []; + + /** + * The component alias name. + * + * @var string + */ + public $componentName; + + /** + * The component attributes. + * + * @var \Illuminate\View\ComponentAttributeBag + */ + public $attributes; + + /** + * Get the view / view contents that represent the component. + * + * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\Support\Htmlable|\Closure|string + */ + abstract public function render(); + + /** + * Resolve the Blade view or view file that should be used when rendering the component. + * + * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\Support\Htmlable|\Closure|string + */ + public function resolveView() + { + $view = $this->render(); + + if ($view instanceof ViewContract) { + return $view; + } + + if ($view instanceof Htmlable) { + return $view; + } + + $resolver = function ($view) { + $factory = Container::getInstance()->make('view'); + + return strlen($view) <= PHP_MAXPATHLEN && $factory->exists($view) + ? $view + : $this->createBladeViewFromString($factory, $view); + }; + + return $view instanceof Closure ? function (array $data = []) use ($view, $resolver) { + return $resolver($view($data)); + } + : $resolver($view); + } + + /** + * Create a Blade view with the raw component string content. + * + * @param \Illuminate\Contracts\View\Factory $factory + * @param string $contents + * @return string + */ + protected function createBladeViewFromString($factory, $contents) + { + $factory->addNamespace( + '__components', + $directory = Container::getInstance()['config']->get('view.compiled') + ); + + if (! is_file($viewFile = $directory.'/'.sha1($contents).'.blade.php')) { + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($viewFile, $contents); + } + + return '__components::'.basename($viewFile, '.blade.php'); + } + + /** + * Get the data that should be supplied to the view. + * + * @author Freek Van der Herten + * @author Brent Roose + * + * @return array + */ + public function data() + { + $this->attributes = $this->attributes ?: $this->newAttributeBag(); + + return array_merge($this->extractPublicProperties(), $this->extractPublicMethods()); + } + + /** + * Extract the public properties for the component. + * + * @return array + */ + protected function extractPublicProperties() + { + $class = get_class($this); + + if (! isset(static::$propertyCache[$class])) { + $reflection = new ReflectionClass($this); + + static::$propertyCache[$class] = collect($reflection->getProperties(ReflectionProperty::IS_PUBLIC)) + ->reject(function (ReflectionProperty $property) { + return $property->isStatic(); + }) + ->reject(function (ReflectionProperty $property) { + return $this->shouldIgnore($property->getName()); + }) + ->map(function (ReflectionProperty $property) { + return $property->getName(); + })->all(); + } + + $values = []; + + foreach (static::$propertyCache[$class] as $property) { + $values[$property] = $this->{$property}; + } + + return $values; + } + + /** + * Extract the public methods for the component. + * + * @return array + */ + protected function extractPublicMethods() + { + $class = get_class($this); + + if (! isset(static::$methodCache[$class])) { + $reflection = new ReflectionClass($this); + + static::$methodCache[$class] = collect($reflection->getMethods(ReflectionMethod::IS_PUBLIC)) + ->reject(function (ReflectionMethod $method) { + return $this->shouldIgnore($method->getName()); + }) + ->map(function (ReflectionMethod $method) { + return $method->getName(); + }); + } + + $values = []; + + foreach (static::$methodCache[$class] as $method) { + $values[$method] = $this->createVariableFromMethod(new ReflectionMethod($this, $method)); + } + + return $values; + } + + /** + * Create a callable variable from the given method. + * + * @param \ReflectionMethod $method + * @return mixed + */ + protected function createVariableFromMethod(ReflectionMethod $method) + { + return $method->getNumberOfParameters() === 0 + ? $this->createInvokableVariable($method->getName()) + : Closure::fromCallable([$this, $method->getName()]); + } + + /** + * Create an invokable, toStringable variable for the given component method. + * + * @param string $method + * @return \Illuminate\View\InvokableComponentVariable + */ + protected function createInvokableVariable(string $method) + { + return new InvokableComponentVariable(function () use ($method) { + return $this->{$method}(); + }); + } + + /** + * Determine if the given property / method should be ignored. + * + * @param string $name + * @return bool + */ + protected function shouldIgnore($name) + { + return Str::startsWith($name, '__') || + in_array($name, $this->ignoredMethods()); + } + + /** + * Get the methods that should be ignored. + * + * @return array + */ + protected function ignoredMethods() + { + return array_merge([ + 'data', + 'render', + 'resolveView', + 'shouldRender', + 'view', + 'withName', + 'withAttributes', + ], $this->except); + } + + /** + * Set the component alias name. + * + * @param string $name + * @return $this + */ + public function withName($name) + { + $this->componentName = $name; + + return $this; + } + + /** + * Set the extra attributes that the component should make available. + * + * @param array $attributes + * @return $this + */ + public function withAttributes(array $attributes) + { + $this->attributes = $this->attributes ?: $this->newAttributeBag(); + + $this->attributes->setAttributes($attributes); + + return $this; + } + + /** + * Get a new attribute bag instance. + * + * @param array $attributes + * @return \Illuminate\View\ComponentAttributeBag + */ + protected function newAttributeBag(array $attributes = []) + { + return new ComponentAttributeBag($attributes); + } + + /** + * Determine if the component should be rendered. + * + * @return bool + */ + public function shouldRender() + { + return true; + } +} diff --git a/src/Illuminate/View/ComponentAttributeBag.php b/src/Illuminate/View/ComponentAttributeBag.php new file mode 100644 index 0000000000000000000000000000000000000000..255d16454127c7102ba9a1819c5c3507751164bf --- /dev/null +++ b/src/Illuminate/View/ComponentAttributeBag.php @@ -0,0 +1,403 @@ +<?php + +namespace Illuminate\View; + +use ArrayAccess; +use ArrayIterator; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Support\Arr; +use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\Conditionable; +use Illuminate\Support\Traits\Macroable; +use IteratorAggregate; + +class ComponentAttributeBag implements ArrayAccess, Htmlable, IteratorAggregate +{ + use Conditionable, Macroable; + + /** + * The raw array of attributes. + * + * @var array + */ + protected $attributes = []; + + /** + * Create a new component attribute bag instance. + * + * @param array $attributes + * @return void + */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + /** + * Get the first attribute's value. + * + * @param mixed $default + * @return mixed + */ + public function first($default = null) + { + return $this->getIterator()->current() ?? value($default); + } + + /** + * Get a given attribute from the attribute array. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get($key, $default = null) + { + return $this->attributes[$key] ?? value($default); + } + + /** + * Determine if a given attribute exists in the attribute array. + * + * @param string $key + * @return bool + */ + public function has($key) + { + return array_key_exists($key, $this->attributes); + } + + /** + * Only include the given attribute from the attribute array. + * + * @param mixed $keys + * @return static + */ + public function only($keys) + { + if (is_null($keys)) { + $values = $this->attributes; + } else { + $keys = Arr::wrap($keys); + + $values = Arr::only($this->attributes, $keys); + } + + return new static($values); + } + + /** + * Exclude the given attribute from the attribute array. + * + * @param mixed|array $keys + * @return static + */ + public function except($keys) + { + if (is_null($keys)) { + $values = $this->attributes; + } else { + $keys = Arr::wrap($keys); + + $values = Arr::except($this->attributes, $keys); + } + + return new static($values); + } + + /** + * Filter the attributes, returning a bag of attributes that pass the filter. + * + * @param callable $callback + * @return static + */ + public function filter($callback) + { + return new static(collect($this->attributes)->filter($callback)->all()); + } + + /** + * Return a bag of attributes that have keys starting with the given value / pattern. + * + * @param string|string[] $needles + * @return static + */ + public function whereStartsWith($needles) + { + return $this->filter(function ($value, $key) use ($needles) { + return Str::startsWith($key, $needles); + }); + } + + /** + * Return a bag of attributes with keys that do not start with the given value / pattern. + * + * @param string|string[] $needles + * @return static + */ + public function whereDoesntStartWith($needles) + { + return $this->filter(function ($value, $key) use ($needles) { + return ! Str::startsWith($key, $needles); + }); + } + + /** + * Return a bag of attributes that have keys starting with the given value / pattern. + * + * @param string|string[] $needles + * @return static + */ + public function thatStartWith($needles) + { + return $this->whereStartsWith($needles); + } + + /** + * Exclude the given attribute from the attribute array. + * + * @param mixed|array $keys + * @return static + */ + public function exceptProps($keys) + { + $props = []; + + foreach ($keys as $key => $defaultValue) { + $key = is_numeric($key) ? $defaultValue : $key; + + $props[] = $key; + $props[] = Str::kebab($key); + } + + return $this->except($props); + } + + /** + * Conditionally merge classes into the attribute bag. + * + * @param mixed|array $classList + * @return static + */ + public function class($classList) + { + $classList = Arr::wrap($classList); + + return $this->merge(['class' => Arr::toCssClasses($classList)]); + } + + /** + * Merge additional attributes / values into the attribute bag. + * + * @param array $attributeDefaults + * @param bool $escape + * @return static + */ + public function merge(array $attributeDefaults = [], $escape = true) + { + $attributeDefaults = array_map(function ($value) use ($escape) { + return $this->shouldEscapeAttributeValue($escape, $value) + ? e($value) + : $value; + }, $attributeDefaults); + + [$appendableAttributes, $nonAppendableAttributes] = collect($this->attributes) + ->partition(function ($value, $key) use ($attributeDefaults) { + return $key === 'class' || + (isset($attributeDefaults[$key]) && + $attributeDefaults[$key] instanceof AppendableAttributeValue); + }); + + $attributes = $appendableAttributes->mapWithKeys(function ($value, $key) use ($attributeDefaults, $escape) { + $defaultsValue = isset($attributeDefaults[$key]) && $attributeDefaults[$key] instanceof AppendableAttributeValue + ? $this->resolveAppendableAttributeDefault($attributeDefaults, $key, $escape) + : ($attributeDefaults[$key] ?? ''); + + return [$key => implode(' ', array_unique(array_filter([$defaultsValue, $value])))]; + })->merge($nonAppendableAttributes)->all(); + + return new static(array_merge($attributeDefaults, $attributes)); + } + + /** + * Determine if the specific attribute value should be escaped. + * + * @param bool $escape + * @param mixed $value + * @return bool + */ + protected function shouldEscapeAttributeValue($escape, $value) + { + if (! $escape) { + return false; + } + + return ! is_object($value) && + ! is_null($value) && + ! is_bool($value); + } + + /** + * Create a new appendable attribute value. + * + * @param mixed $value + * @return \Illuminate\View\AppendableAttributeValue + */ + public function prepends($value) + { + return new AppendableAttributeValue($value); + } + + /** + * Resolve an appendable attribute value default value. + * + * @param array $attributeDefaults + * @param string $key + * @param bool $escape + * @return mixed + */ + protected function resolveAppendableAttributeDefault($attributeDefaults, $key, $escape) + { + if ($this->shouldEscapeAttributeValue($escape, $value = $attributeDefaults[$key]->value)) { + $value = e($value); + } + + return $value; + } + + /** + * Get all of the raw attributes. + * + * @return array + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * Set the underlying attributes. + * + * @param array $attributes + * @return void + */ + public function setAttributes(array $attributes) + { + if (isset($attributes['attributes']) && + $attributes['attributes'] instanceof self) { + $parentBag = $attributes['attributes']; + + unset($attributes['attributes']); + + $attributes = $parentBag->merge($attributes, $escape = false)->getAttributes(); + } + + $this->attributes = $attributes; + } + + /** + * Get content as a string of HTML. + * + * @return string + */ + public function toHtml() + { + return (string) $this; + } + + /** + * Merge additional attributes / values into the attribute bag. + * + * @param array $attributeDefaults + * @return \Illuminate\Support\HtmlString + */ + public function __invoke(array $attributeDefaults = []) + { + return new HtmlString((string) $this->merge($attributeDefaults)); + } + + /** + * Determine if the given offset exists. + * + * @param string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->attributes[$offset]); + } + + /** + * Get the value at the given offset. + * + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->get($offset); + } + + /** + * Set the value at a given offset. + * + * @param string $offset + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + $this->attributes[$offset] = $value; + } + + /** + * Remove the value at the given offset. + * + * @param string $offset + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->attributes[$offset]); + } + + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->attributes); + } + + /** + * Implode the attributes into a single HTML ready string. + * + * @return string + */ + public function __toString() + { + $string = ''; + + foreach ($this->attributes as $key => $value) { + if ($value === false || is_null($value)) { + continue; + } + + if ($value === true) { + $value = $key; + } + + $string .= ' '.$key.'="'.str_replace('"', '\\"', trim($value)).'"'; + } + + return trim($string); + } +} diff --git a/src/Illuminate/View/ComponentSlot.php b/src/Illuminate/View/ComponentSlot.php new file mode 100644 index 0000000000000000000000000000000000000000..85665ad64575dd161550b42b27a1f0a44e2003c3 --- /dev/null +++ b/src/Illuminate/View/ComponentSlot.php @@ -0,0 +1,89 @@ +<?php + +namespace Illuminate\View; + +use Illuminate\Contracts\Support\Htmlable; + +class ComponentSlot implements Htmlable +{ + /** + * The slot attribute bag. + * + * @var \Illuminate\View\ComponentAttributeBag + */ + public $attributes; + + /** + * The slot contents. + * + * @var string + */ + protected $contents; + + /** + * Create a new slot instance. + * + * @param string $contents + * @param array $attributes + * @return void + */ + public function __construct($contents = '', $attributes = []) + { + $this->contents = $contents; + + $this->withAttributes($attributes); + } + + /** + * Set the extra attributes that the slot should make available. + * + * @param array $attributes + * @return $this + */ + public function withAttributes(array $attributes) + { + $this->attributes = new ComponentAttributeBag($attributes); + + return $this; + } + + /** + * Get the slot's HTML string. + * + * @return string + */ + public function toHtml() + { + return $this->contents; + } + + /** + * Determine if the slot is empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->contents === ''; + } + + /** + * Determine if the slot is not empty. + * + * @return bool + */ + public function isNotEmpty() + { + return ! $this->isEmpty(); + } + + /** + * Get the slot's HTML string. + * + * @return string + */ + public function __toString() + { + return $this->toHtml(); + } +} diff --git a/src/Illuminate/View/Concerns/ManagesComponents.php b/src/Illuminate/View/Concerns/ManagesComponents.php index 32516fcd3800435d97165736996be23454666d5b..f24908f1e93684b279b96d51d108d7b81d3faab3 100644 --- a/src/Illuminate/View/Concerns/ManagesComponents.php +++ b/src/Illuminate/View/Concerns/ManagesComponents.php @@ -2,9 +2,11 @@ namespace Illuminate\View\Concerns; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\View\View; use Illuminate\Support\Arr; use Illuminate\Support\HtmlString; -use InvalidArgumentException; +use Illuminate\View\ComponentSlot; trait ManagesComponents { @@ -22,6 +24,13 @@ trait ManagesComponents */ protected $componentData = []; + /** + * The component data for the component that is currently being rendered. + * + * @var array + */ + protected $currentComponentData = []; + /** * The slot contents for the component. * @@ -39,14 +48,14 @@ trait ManagesComponents /** * Start a component rendering process. * - * @param string $name + * @param \Illuminate\Contracts\View\View|\Illuminate\Contracts\Support\Htmlable|\Closure|string $view * @param array $data * @return void */ - public function startComponent($name, array $data = []) + public function startComponent($view, array $data = []) { if (ob_start()) { - $this->componentStack[] = $name; + $this->componentStack[] = $view; $this->componentData[$this->currentComponent()] = $data; @@ -77,43 +86,95 @@ trait ManagesComponents */ public function renderComponent() { - $name = array_pop($this->componentStack); + $view = array_pop($this->componentStack); + + $this->currentComponentData = array_merge( + $previousComponentData = $this->currentComponentData, + $data = $this->componentData() + ); - return $this->make($name, $this->componentData($name))->render(); + try { + $view = value($view, $data); + + if ($view instanceof View) { + return $view->with($data)->render(); + } elseif ($view instanceof Htmlable) { + return $view->toHtml(); + } else { + return $this->make($view, $data)->render(); + } + } finally { + $this->currentComponentData = $previousComponentData; + } } /** * Get the data for the given component. * - * @param string $name * @return array */ - protected function componentData($name) + protected function componentData() { + $defaultSlot = new HtmlString(trim(ob_get_clean())); + + $slots = array_merge([ + '__default' => $defaultSlot, + ], $this->slots[count($this->componentStack)]); + return array_merge( $this->componentData[count($this->componentStack)], - ['slot' => new HtmlString(trim(ob_get_clean()))], - $this->slots[count($this->componentStack)] + ['slot' => $defaultSlot], + $this->slots[count($this->componentStack)], + ['__laravel_slots' => $slots] ); } + /** + * Get an item from the component data that exists above the current component. + * + * @param string $key + * @param mixed $default + * @return mixed|null + */ + public function getConsumableComponentData($key, $default = null) + { + if (array_key_exists($key, $this->currentComponentData)) { + return $this->currentComponentData[$key]; + } + + $currentComponent = count($this->componentStack); + + if ($currentComponent === 0) { + return value($default); + } + + for ($i = $currentComponent - 1; $i >= 0; $i--) { + $data = $this->componentData[$i] ?? []; + + if (array_key_exists($key, $data)) { + return $data[$key]; + } + } + + return value($default); + } + /** * Start the slot rendering process. * * @param string $name * @param string|null $content + * @param array $attributes * @return void */ - public function slot($name, $content = null) + public function slot($name, $content = null, $attributes = []) { - if (func_num_args() > 2) { - throw new InvalidArgumentException('You passed too many arguments to the ['.$name.'] slot.'); - } elseif (func_num_args() === 2) { + if (func_num_args() === 2 || $content !== null) { $this->slots[$this->currentComponent()][$name] = $content; } elseif (ob_start()) { $this->slots[$this->currentComponent()][$name] = ''; - $this->slotStack[$this->currentComponent()][] = $name; + $this->slotStack[$this->currentComponent()][] = [$name, $attributes]; } } @@ -130,8 +191,11 @@ trait ManagesComponents $this->slotStack[$this->currentComponent()] ); - $this->slots[$this->currentComponent()] - [$currentSlot] = new HtmlString(trim(ob_get_clean())); + [$currentName, $currentAttributes] = $currentSlot; + + $this->slots[$this->currentComponent()][$currentName] = new ComponentSlot( + trim(ob_get_clean()), $currentAttributes + ); } /** @@ -143,4 +207,16 @@ trait ManagesComponents { return count($this->componentStack) - 1; } + + /** + * Flush all of the component state. + * + * @return void + */ + protected function flushComponents() + { + $this->componentStack = []; + $this->componentData = []; + $this->currentComponentData = []; + } } diff --git a/src/Illuminate/View/Concerns/ManagesLayouts.php b/src/Illuminate/View/Concerns/ManagesLayouts.php index 29d71552a8ae75b12a2884ab6ed179bc98b00846..f0451265876174f28973d134676fee9a14b2d8f5 100644 --- a/src/Illuminate/View/Concerns/ManagesLayouts.php +++ b/src/Illuminate/View/Concerns/ManagesLayouts.php @@ -3,6 +3,7 @@ namespace Illuminate\View\Concerns; use Illuminate\Contracts\View\View; +use Illuminate\Support\Str; use InvalidArgumentException; trait ManagesLayouts @@ -28,6 +29,13 @@ trait ManagesLayouts */ protected static $parentPlaceholder = []; + /** + * The parent placeholder salt for the request. + * + * @var string + */ + protected static $parentPlaceholderSalt; + /** * Start injecting content into a section. * @@ -168,12 +176,28 @@ trait ManagesLayouts public static function parentPlaceholder($section = '') { if (! isset(static::$parentPlaceholder[$section])) { - static::$parentPlaceholder[$section] = '##parent-placeholder-'.sha1($section).'##'; + $salt = static::parentPlaceholderSalt(); + + static::$parentPlaceholder[$section] = '##parent-placeholder-'.sha1($salt.$section).'##'; } return static::$parentPlaceholder[$section]; } + /** + * Get the parent placeholder salt. + * + * @return string + */ + protected static function parentPlaceholderSalt() + { + if (! static::$parentPlaceholderSalt) { + return static::$parentPlaceholderSalt = Str::random(40); + } + + return static::$parentPlaceholderSalt; + } + /** * Check if section exists. * @@ -185,6 +209,17 @@ trait ManagesLayouts return array_key_exists($name, $this->sections); } + /** + * Check if section does not exist. + * + * @param string $name + * @return bool + */ + public function sectionMissing($name) + { + return ! $this->hasSection($name); + } + /** * Get the contents of a section. * diff --git a/src/Illuminate/View/DynamicComponent.php b/src/Illuminate/View/DynamicComponent.php new file mode 100644 index 0000000000000000000000000000000000000000..cea66e77b304c41eddb11e3e4075c9820eb06e8c --- /dev/null +++ b/src/Illuminate/View/DynamicComponent.php @@ -0,0 +1,172 @@ +<?php + +namespace Illuminate\View; + +use Illuminate\Container\Container; +use Illuminate\Support\Str; +use Illuminate\View\Compilers\ComponentTagCompiler; + +class DynamicComponent extends Component +{ + /** + * The name of the component. + * + * @var string + */ + public $component; + + /** + * The component tag compiler instance. + * + * @var \Illuminate\View\Compilers\BladeTagCompiler + */ + protected static $compiler; + + /** + * The cached component classes. + * + * @var array + */ + protected static $componentClasses = []; + + /** + * Create a new component instance. + * + * @param string $component + * @return void + */ + public function __construct(string $component) + { + $this->component = $component; + } + + /** + * Get the view / contents that represent the component. + * + * @return \Illuminate\Contracts\View\View|string + */ + public function render() + { + $template = <<<'EOF' +<?php extract(collect($attributes->getAttributes())->mapWithKeys(function ($value, $key) { return [Illuminate\Support\Str::camel(str_replace([':', '.'], ' ', $key)) => $value]; })->all(), EXTR_SKIP); ?> +{{ props }} +<x-{{ component }} {{ bindings }} {{ attributes }}> +{{ slots }} +{{ defaultSlot }} +</x-{{ component }}> +EOF; + + return function ($data) use ($template) { + $bindings = $this->bindings($class = $this->classForComponent()); + + return str_replace( + [ + '{{ component }}', + '{{ props }}', + '{{ bindings }}', + '{{ attributes }}', + '{{ slots }}', + '{{ defaultSlot }}', + ], + [ + $this->component, + $this->compileProps($bindings), + $this->compileBindings($bindings), + class_exists($class) ? '{{ $attributes }}' : '', + $this->compileSlots($data['__laravel_slots']), + '{{ $slot ?? "" }}', + ], + $template + ); + }; + } + + /** + * Compile the @props directive for the component. + * + * @param array $bindings + * @return string + */ + protected function compileProps(array $bindings) + { + if (empty($bindings)) { + return ''; + } + + return '@props('.'[\''.implode('\',\'', collect($bindings)->map(function ($dataKey) { + return Str::camel($dataKey); + })->all()).'\']'.')'; + } + + /** + * Compile the bindings for the component. + * + * @param array $bindings + * @return string + */ + protected function compileBindings(array $bindings) + { + return collect($bindings)->map(function ($key) { + return ':'.$key.'="$'.Str::camel(str_replace([':', '.'], ' ', $key)).'"'; + })->implode(' '); + } + + /** + * Compile the slots for the component. + * + * @param array $slots + * @return string + */ + protected function compileSlots(array $slots) + { + return collect($slots)->map(function ($slot, $name) { + return $name === '__default' ? null : '<x-slot name="'.$name.'" '.((string) $slot->attributes).'>{{ $'.$name.' }}</x-slot>'; + })->filter()->implode(PHP_EOL); + } + + /** + * Get the class for the current component. + * + * @return string + */ + protected function classForComponent() + { + if (isset(static::$componentClasses[$this->component])) { + return static::$componentClasses[$this->component]; + } + + return static::$componentClasses[$this->component] = + $this->compiler()->componentClass($this->component); + } + + /** + * Get the names of the variables that should be bound to the component. + * + * @param string $class + * @return array + */ + protected function bindings(string $class) + { + [$data, $attributes] = $this->compiler()->partitionDataAndAttributes($class, $this->attributes->getAttributes()); + + return array_keys($data->all()); + } + + /** + * Get an instance of the Blade tag compiler. + * + * @return \Illuminate\View\Compilers\ComponentTagCompiler + */ + protected function compiler() + { + if (! static::$compiler) { + static::$compiler = new ComponentTagCompiler( + Container::getInstance()->make('blade.compiler')->getClassComponentAliases(), + Container::getInstance()->make('blade.compiler')->getClassComponentNamespaces(), + Container::getInstance()->make('blade.compiler') + ); + } + + return static::$compiler; + } +} diff --git a/src/Illuminate/View/Engines/CompilerEngine.php b/src/Illuminate/View/Engines/CompilerEngine.php index 03717bad0b51bd94259721f771c97c2d4504df25..dca6a8710560a0b210308c9f9b1c3dcd185ec5c2 100755 --- a/src/Illuminate/View/Engines/CompilerEngine.php +++ b/src/Illuminate/View/Engines/CompilerEngine.php @@ -2,9 +2,10 @@ namespace Illuminate\View\Engines; -use ErrorException; -use Exception; +use Illuminate\Filesystem\Filesystem; use Illuminate\View\Compilers\CompilerInterface; +use Illuminate\View\ViewException; +use Throwable; class CompilerEngine extends PhpEngine { @@ -23,13 +24,16 @@ class CompilerEngine extends PhpEngine protected $lastCompiled = []; /** - * Create a new Blade view engine instance. + * Create a new compiler engine instance. * * @param \Illuminate\View\Compilers\CompilerInterface $compiler + * @param \Illuminate\Filesystem\Filesystem|null $files * @return void */ - public function __construct(CompilerInterface $compiler) + public function __construct(CompilerInterface $compiler, Filesystem $files = null) { + parent::__construct($files ?: new Filesystem); + $this->compiler = $compiler; } @@ -51,12 +55,10 @@ class CompilerEngine extends PhpEngine $this->compiler->compile($path); } - $compiled = $this->compiler->getCompiledPath($path); - // Once we have the path to the compiled file, we will evaluate the paths with // typical PHP just like any other templates. We also keep a stack of views // which have been rendered for right exception messages to be generated. - $results = $this->evaluatePath($compiled, $data); + $results = $this->evaluatePath($this->compiler->getCompiledPath($path), $data); array_pop($this->lastCompiled); @@ -66,15 +68,15 @@ class CompilerEngine extends PhpEngine /** * Handle a view exception. * - * @param \Exception $e + * @param \Throwable $e * @param int $obLevel * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleViewException(Exception $e, $obLevel) + protected function handleViewException(Throwable $e, $obLevel) { - $e = new ErrorException($this->getMessage($e), 0, 1, $e->getFile(), $e->getLine(), $e); + $e = new ViewException($this->getMessage($e), 0, 1, $e->getFile(), $e->getLine(), $e); parent::handleViewException($e, $obLevel); } @@ -82,10 +84,10 @@ class CompilerEngine extends PhpEngine /** * Get the exception message for an exception. * - * @param \Exception $e + * @param \Throwable $e * @return string */ - protected function getMessage(Exception $e) + protected function getMessage(Throwable $e) { return $e->getMessage().' (View: '.realpath(last($this->lastCompiled)).')'; } diff --git a/src/Illuminate/View/Engines/EngineResolver.php b/src/Illuminate/View/Engines/EngineResolver.php index d0edb7367df6459081936380e70318f191df5488..674040770be2cb041315077d682ae25c2fad1a35 100755 --- a/src/Illuminate/View/Engines/EngineResolver.php +++ b/src/Illuminate/View/Engines/EngineResolver.php @@ -32,7 +32,7 @@ class EngineResolver */ public function register($engine, Closure $resolver) { - unset($this->resolved[$engine]); + $this->forget($engine); $this->resolvers[$engine] = $resolver; } @@ -57,4 +57,15 @@ class EngineResolver throw new InvalidArgumentException("Engine [{$engine}] not found."); } + + /** + * Remove a resolved engine. + * + * @param string $engine + * @return void + */ + public function forget($engine) + { + unset($this->resolved[$engine]); + } } diff --git a/src/Illuminate/View/Engines/FileEngine.php b/src/Illuminate/View/Engines/FileEngine.php index f73c4a79a63371c4d5253b0d1169e8d45485ab65..992f6758d98058f9abcd11fbea03f3ef4acfa78d 100644 --- a/src/Illuminate/View/Engines/FileEngine.php +++ b/src/Illuminate/View/Engines/FileEngine.php @@ -3,9 +3,28 @@ namespace Illuminate\View\Engines; use Illuminate\Contracts\View\Engine; +use Illuminate\Filesystem\Filesystem; class FileEngine implements Engine { + /** + * The filesystem instance. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $files; + + /** + * Create a new file engine instance. + * + * @param \Illuminate\Filesystem\Filesystem $files + * @return void + */ + public function __construct(Filesystem $files) + { + $this->files = $files; + } + /** * Get the evaluated contents of the view. * @@ -15,6 +34,6 @@ class FileEngine implements Engine */ public function get($path, array $data = []) { - return file_get_contents($path); + return $this->files->get($path); } } diff --git a/src/Illuminate/View/Engines/PhpEngine.php b/src/Illuminate/View/Engines/PhpEngine.php index 709d76fd8b9e04167b76caf0a58cd8b5a801296a..13525aeeab53525b4b6415a140a428a9297665c5 100755 --- a/src/Illuminate/View/Engines/PhpEngine.php +++ b/src/Illuminate/View/Engines/PhpEngine.php @@ -2,13 +2,30 @@ namespace Illuminate\View\Engines; -use Exception; use Illuminate\Contracts\View\Engine; -use Symfony\Component\Debug\Exception\FatalThrowableError; +use Illuminate\Filesystem\Filesystem; use Throwable; class PhpEngine implements Engine { + /** + * The filesystem instance. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $files; + + /** + * Create a new file engine instance. + * + * @param \Illuminate\Filesystem\Filesystem $files + * @return void + */ + public function __construct(Filesystem $files) + { + $this->files = $files; + } + /** * Get the evaluated contents of the view. * @@ -24,27 +41,23 @@ class PhpEngine implements Engine /** * Get the evaluated contents of the view at the given path. * - * @param string $__path - * @param array $__data + * @param string $path + * @param array $data * @return string */ - protected function evaluatePath($__path, $__data) + protected function evaluatePath($path, $data) { $obLevel = ob_get_level(); ob_start(); - extract($__data, EXTR_SKIP); - // We'll evaluate the contents of the view inside a try/catch block so we can // flush out any stray output that might get out before an error occurs or // an exception is thrown. This prevents any partial views from leaking. try { - include $__path; - } catch (Exception $e) { - $this->handleViewException($e, $obLevel); + $this->files->getRequire($path, $data); } catch (Throwable $e) { - $this->handleViewException(new FatalThrowableError($e), $obLevel); + $this->handleViewException($e, $obLevel); } return ltrim(ob_get_clean()); @@ -53,13 +66,13 @@ class PhpEngine implements Engine /** * Handle a view exception. * - * @param \Exception $e + * @param \Throwable $e * @param int $obLevel * @return void * - * @throws \Exception + * @throws \Throwable */ - protected function handleViewException(Exception $e, $obLevel) + protected function handleViewException(Throwable $e, $obLevel) { while (ob_get_level() > $obLevel) { ob_end_clean(); diff --git a/src/Illuminate/View/Factory.php b/src/Illuminate/View/Factory.php index e06741ceedc810de4a4927d8a48a8fd30be7d6a7..de431f77e41fa27bb705a5d4552e980aad3e77d3 100755 --- a/src/Illuminate/View/Factory.php +++ b/src/Illuminate/View/Factory.php @@ -83,6 +83,13 @@ class Factory implements FactoryContract */ protected $renderCount = 0; + /** + * The "once" block IDs that have been rendered. + * + * @var array + */ + protected $renderedOnce = []; + /** * Create a new view factory instance. * @@ -182,6 +189,20 @@ class Factory implements FactoryContract return $this->make($view, $this->parseData($data), $mergeData)->render(); } + /** + * Get the rendered content of the view based on the negation of a given condition. + * + * @param bool $condition + * @param string $view + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @param array $mergeData + * @return string + */ + public function renderUnless($condition, $view, $data = [], $mergeData = []) + { + return $this->renderWhen(! $condition, $view, $data, $mergeData); + } + /** * Get the rendered contents of a partial from a loop. * @@ -281,7 +302,7 @@ class Factory implements FactoryContract public function getEngineFromPath($path) { if (! $extension = $this->getExtension($path)) { - throw new InvalidArgumentException("Unrecognized extension in file: {$path}"); + throw new InvalidArgumentException("Unrecognized extension in file: {$path}."); } $engine = $this->extensions[$extension]; @@ -293,7 +314,7 @@ class Factory implements FactoryContract * Get the extension used by the view file. * * @param string $path - * @return string + * @return string|null */ protected function getExtension($path) { @@ -352,6 +373,28 @@ class Factory implements FactoryContract return $this->renderCount == 0; } + /** + * Determine if the given once token has been rendered. + * + * @param string $id + * @return bool + */ + public function hasRenderedOnce(string $id) + { + return isset($this->renderedOnce[$id]); + } + + /** + * Mark the given once token as having been rendered. + * + * @param string $id + * @return void + */ + public function markAsRenderedOnce(string $id) + { + $this->renderedOnce[$id] = true; + } + /** * Add a location to the array of view locations. * @@ -434,9 +477,11 @@ class Factory implements FactoryContract public function flushState() { $this->renderCount = 0; + $this->renderedOnce = []; $this->flushSections(); $this->flushStacks(); + $this->flushComponents(); } /** diff --git a/src/Illuminate/View/FileViewFinder.php b/src/Illuminate/View/FileViewFinder.php index c518524fe8082cc2cfa7f8db52ce2956c2577d12..a488a9d56b6f1fc98f9ba6048f39927649dfdb44 100755 --- a/src/Illuminate/View/FileViewFinder.php +++ b/src/Illuminate/View/FileViewFinder.php @@ -38,7 +38,7 @@ class FileViewFinder implements ViewFinderInterface /** * Register a view extension with the finder. * - * @var array + * @var string[] */ protected $extensions = ['blade.php', 'php', 'css', 'html']; diff --git a/src/Illuminate/View/InvokableComponentVariable.php b/src/Illuminate/View/InvokableComponentVariable.php new file mode 100644 index 0000000000000000000000000000000000000000..b9db6570be51929b6cc8341744ae70012d787194 --- /dev/null +++ b/src/Illuminate/View/InvokableComponentVariable.php @@ -0,0 +1,96 @@ +<?php + +namespace Illuminate\View; + +use ArrayIterator; +use Closure; +use Illuminate\Contracts\Support\DeferringDisplayableValue; +use Illuminate\Support\Enumerable; +use IteratorAggregate; + +class InvokableComponentVariable implements DeferringDisplayableValue, IteratorAggregate +{ + /** + * The callable instance to resolve the variable value. + * + * @var \Closure + */ + protected $callable; + + /** + * Create a new variable instance. + * + * @param \Closure $callable + * @return void + */ + public function __construct(Closure $callable) + { + $this->callable = $callable; + } + + /** + * Resolve the displayable value that the class is deferring. + * + * @return \Illuminate\Contracts\Support\Htmlable|string + */ + public function resolveDisplayableValue() + { + return $this->__invoke(); + } + + /** + * Get an interator instance for the variable. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + $result = $this->__invoke(); + + return new ArrayIterator($result instanceof Enumerable ? $result->all() : $result); + } + + /** + * Dynamically proxy attribute access to the variable. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + return $this->__invoke()->{$key}; + } + + /** + * Dynamically proxy method access to the variable. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->__invoke()->{$method}(...$parameters); + } + + /** + * Resolve the variable. + * + * @return mixed + */ + public function __invoke() + { + return call_user_func($this->callable); + } + + /** + * Resolve the variable as a string. + * + * @return mixed + */ + public function __toString() + { + return (string) $this->__invoke(); + } +} diff --git a/src/Illuminate/View/View.php b/src/Illuminate/View/View.php index ca246f785d2df84dedde575fa7755231eaedfa1a..a1969350c18a867ff7aad36f38611813e813d52d 100755 --- a/src/Illuminate/View/View.php +++ b/src/Illuminate/View/View.php @@ -4,7 +4,6 @@ namespace Illuminate\View; use ArrayAccess; use BadMethodCallException; -use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\MessageProvider; @@ -14,6 +13,7 @@ use Illuminate\Contracts\View\View as ViewContract; use Illuminate\Support\MessageBag; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\ViewErrorBag; use Throwable; class View implements ArrayAccess, Htmlable, ViewContract @@ -81,7 +81,7 @@ class View implements ArrayAccess, Htmlable, ViewContract * Get the string contents of the view. * * @param callable|null $callback - * @return array|string + * @return string * * @throws \Throwable */ @@ -98,10 +98,6 @@ class View implements ArrayAccess, Htmlable, ViewContract $this->factory->flushStateIfDoneRendering(); return ! is_null($response) ? $response : $contents; - } catch (Exception $e) { - $this->factory->flushState(); - - throw $e; } catch (Throwable $e) { $this->factory->flushState(); @@ -210,25 +206,27 @@ class View implements ArrayAccess, Htmlable, ViewContract * Add validation errors to the view. * * @param \Illuminate\Contracts\Support\MessageProvider|array $provider + * @param string $bag * @return $this */ - public function withErrors($provider) + public function withErrors($provider, $bag = 'default') { - $this->with('errors', $this->formatErrors($provider)); - - return $this; + return $this->with('errors', (new ViewErrorBag)->put( + $bag, $this->formatErrors($provider) + )); } /** - * Format the given message provider into a MessageBag. + * Parse the given errors into an appropriate value. * - * @param \Illuminate\Contracts\Support\MessageProvider|array $provider + * @param \Illuminate\Contracts\Support\MessageProvider|array|string $provider * @return \Illuminate\Support\MessageBag */ protected function formatErrors($provider) { return $provider instanceof MessageProvider - ? $provider->getMessageBag() : new MessageBag((array) $provider); + ? $provider->getMessageBag() + : new MessageBag((array) $provider); } /** @@ -308,6 +306,7 @@ class View implements ArrayAccess, Htmlable, ViewContract * @param string $key * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($key) { return array_key_exists($key, $this->data); @@ -319,6 +318,7 @@ class View implements ArrayAccess, Htmlable, ViewContract * @param string $key * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->data[$key]; @@ -331,6 +331,7 @@ class View implements ArrayAccess, Htmlable, ViewContract * @param mixed $value * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->with($key, $value); @@ -342,6 +343,7 @@ class View implements ArrayAccess, Htmlable, ViewContract * @param string $key * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->data[$key]); diff --git a/src/Illuminate/View/ViewException.php b/src/Illuminate/View/ViewException.php new file mode 100644 index 0000000000000000000000000000000000000000..e6797a29a1e7cfb1834f000f9c6eab67f0536f3c --- /dev/null +++ b/src/Illuminate/View/ViewException.php @@ -0,0 +1,41 @@ +<?php + +namespace Illuminate\View; + +use ErrorException; +use Illuminate\Container\Container; +use Illuminate\Support\Reflector; + +class ViewException extends ErrorException +{ + /** + * Report the exception. + * + * @return bool|null + */ + public function report() + { + $exception = $this->getPrevious(); + + if (Reflector::isCallable($reportCallable = [$exception, 'report'])) { + return Container::getInstance()->call($reportCallable); + } + + return false; + } + + /** + * Render the exception into an HTTP response. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function render($request) + { + $exception = $this->getPrevious(); + + if ($exception && method_exists($exception, 'render')) { + return $exception->render($request); + } + } +} diff --git a/src/Illuminate/View/ViewName.php b/src/Illuminate/View/ViewName.php index 03efcc5a22ed4bf3581f648e8376f7951ab988cc..9b803332fbb3789b5dea102c9cd7991278ac2b1d 100644 --- a/src/Illuminate/View/ViewName.php +++ b/src/Illuminate/View/ViewName.php @@ -5,7 +5,7 @@ namespace Illuminate\View; class ViewName { /** - * Normalize the given event name. + * Normalize the given view name. * * @param string $name * @return string diff --git a/src/Illuminate/View/ViewServiceProvider.php b/src/Illuminate/View/ViewServiceProvider.php index c06624f82f7ce020099f6376dbd34e898170c4e4..7eb731f53cfb53071a8ee181ac1d980c465cc9fa 100755 --- a/src/Illuminate/View/ViewServiceProvider.php +++ b/src/Illuminate/View/ViewServiceProvider.php @@ -19,11 +19,8 @@ class ViewServiceProvider extends ServiceProvider public function register() { $this->registerFactory(); - $this->registerViewFinder(); - $this->registerBladeCompiler(); - $this->registerEngineResolver(); } @@ -87,10 +84,10 @@ class ViewServiceProvider extends ServiceProvider */ public function registerBladeCompiler() { - $this->app->singleton('blade.compiler', function () { - return new BladeCompiler( - $this->app['files'], $this->app['config']['view.compiled'] - ); + $this->app->singleton('blade.compiler', function ($app) { + return tap(new BladeCompiler($app['files'], $app['config']['view.compiled']), function ($blade) { + $blade->component('dynamic-component', DynamicComponent::class); + }); }); } @@ -124,7 +121,7 @@ class ViewServiceProvider extends ServiceProvider public function registerFileEngine($resolver) { $resolver->register('file', function () { - return new FileEngine; + return new FileEngine($this->app['files']); }); } @@ -137,7 +134,7 @@ class ViewServiceProvider extends ServiceProvider public function registerPhpEngine($resolver) { $resolver->register('php', function () { - return new PhpEngine; + return new PhpEngine($this->app['files']); }); } @@ -150,7 +147,7 @@ class ViewServiceProvider extends ServiceProvider public function registerBladeEngine($resolver) { $resolver->register('blade', function () { - return new CompilerEngine($this->app['blade.compiler']); + return new CompilerEngine($this->app['blade.compiler'], $this->app['files']); }); } } diff --git a/src/Illuminate/View/composer.json b/src/Illuminate/View/composer.json index 2ec1e83219b89ba6e89444769e96aeb69896c8bf..942435b633371f6f59419f5709960e09395833bc 100644 --- a/src/Illuminate/View/composer.json +++ b/src/Illuminate/View/composer.json @@ -14,14 +14,15 @@ } ], "require": { - "php": "^7.2.5|^8.0", + "php": "^7.3|^8.0", "ext-json": "*", - "illuminate/container": "^6.0", - "illuminate/contracts": "^6.0", - "illuminate/events": "^6.0", - "illuminate/filesystem": "^6.0", - "illuminate/support": "^6.0", - "symfony/debug": "^4.3.4" + "illuminate/collections": "^8.0", + "illuminate/container": "^8.0", + "illuminate/contracts": "^8.0", + "illuminate/events": "^8.0", + "illuminate/filesystem": "^8.0", + "illuminate/macroable": "^8.0", + "illuminate/support": "^8.0" }, "autoload": { "psr-4": { @@ -30,7 +31,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.x-dev" + "dev-master": "8.x-dev" } }, "config": { diff --git a/tests/Auth/AuthAccessGateTest.php b/tests/Auth/AuthAccessGateTest.php index 299f6bfb8ed5dc097f380b90fe27774c10974cde..4c68e2f0097466f2b9799428b66d068dfac37415 100644 --- a/tests/Auth/AuthAccessGateTest.php +++ b/tests/Auth/AuthAccessGateTest.php @@ -184,10 +184,12 @@ class AuthAccessGateTest extends TestCase $this->assertTrue($_SERVER['__laravel.gateAfter']); $this->assertFalse($_SERVER['__laravel.gateAfter2']); - unset($_SERVER['__laravel.gateBefore']); - unset($_SERVER['__laravel.gateBefore2']); - unset($_SERVER['__laravel.gateAfter']); - unset($_SERVER['__laravel.gateAfter2']); + unset( + $_SERVER['__laravel.gateBefore'], + $_SERVER['__laravel.gateBefore2'], + $_SERVER['__laravel.gateAfter'], + $_SERVER['__laravel.gateAfter2'] + ); } public function testResourceGatesCanBeDefined() @@ -262,9 +264,9 @@ class AuthAccessGateTest extends TestCase }); $gate->after(function ($user, $ability, $result) { - if ($ability == 'foo') { + if ($ability === 'foo') { $this->assertTrue($result, 'After callback on `foo` should receive true as result'); - } elseif ($ability == 'bar') { + } elseif ($ability === 'bar') { $this->assertFalse($result, 'After callback on `bar` should receive false as result'); } else { $this->assertNull($result, 'After callback on `missing` should receive null as result'); @@ -312,7 +314,7 @@ class AuthAccessGateTest extends TestCase $gate = $this->getBasicGate(); $gate->after(function ($user, $ability, $result) { - return $ability == 'allow'; + return $ability === 'allow'; }); $gate->after(function ($user, $ability, $result) { @@ -328,7 +330,7 @@ class AuthAccessGateTest extends TestCase $gate = $this->getBasicGate(); $gate->define('foo', function ($user) { - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); return true; }); @@ -519,7 +521,7 @@ class AuthAccessGateTest extends TestCase // Assert that the callback receives the new user with ID of 2 instead of ID of 1... $gate->define('foo', function ($user) { - $this->assertEquals(2, $user->id); + $this->assertSame(2, $user->id); return true; }); @@ -541,16 +543,16 @@ class AuthAccessGateTest extends TestCase }; $gate->guessPolicyNamesUsing($guesserCallback); $gate->getPolicyFor('fooClass'); - $this->assertEquals(1, $counter); + $this->assertSame(1, $counter); // now the guesser callback should be present on the new gate as well $newGate = $gate->forUser((object) ['id' => 1]); $newGate->getPolicyFor('fooClass'); - $this->assertEquals(2, $counter); + $this->assertSame(2, $counter); $newGate->getPolicyFor('fooClass'); - $this->assertEquals(3, $counter); + $this->assertSame(3, $counter); } /** @@ -672,7 +674,7 @@ class AuthAccessGateTest extends TestCase $this->assertSame('Not allowed to view as it is not published.', $response->message()); $this->assertFalse($response->allowed()); $this->assertTrue($response->denied()); - $this->assertEquals($response->code(), 'unpublished'); + $this->assertSame('unpublished', $response->code()); } public function testAuthorizeReturnsAnAllowedResponseForATruthyReturn() @@ -687,6 +689,282 @@ class AuthAccessGateTest extends TestCase $this->assertNull($response->message()); } + public function testAllowIfAuthorizesTrue() + { + $response = $this->getBasicGate()->allowIf(true); + + $this->assertTrue($response->allowed()); + } + + public function testAllowIfAuthorizesTruthy() + { + $response = $this->getBasicGate()->allowIf('truthy'); + + $this->assertTrue($response->allowed()); + } + + public function testAllowIfAuthorizesIfGuest() + { + $response = $this->getBasicGate()->forUser(null)->allowIf(true); + + $this->assertTrue($response->allowed()); + } + + public function testAllowIfAuthorizesCallbackTrue() + { + $response = $this->getBasicGate()->allowIf(function ($user) { + $this->assertSame(1, $user->id); + + return true; + }, 'foo', 'bar'); + + $this->assertTrue($response->allowed()); + $this->assertSame('foo', $response->message()); + $this->assertSame('bar', $response->code()); + } + + public function testAllowIfAuthorizesResponseAllowed() + { + $response = $this->getBasicGate()->allowIf(Response::allow('foo', 'bar')); + + $this->assertTrue($response->allowed()); + $this->assertSame('foo', $response->message()); + $this->assertSame('bar', $response->code()); + } + + public function testAllowIfAuthorizesCallbackResponseAllowed() + { + $response = $this->getBasicGate()->allowIf(function () { + return Response::allow('quz', 'qux'); + }, 'foo', 'bar'); + + $this->assertTrue($response->allowed()); + $this->assertSame('quz', $response->message()); + $this->assertSame('qux', $response->code()); + } + + public function testAllowsIfCallbackAcceptsGuestsWhenAuthenticated() + { + $response = $this->getBasicGate()->allowIf(function (stdClass $user = null) { + return $user !== null; + }); + + $this->assertTrue($response->allowed()); + } + + public function testAllowIfCallbackAcceptsGuestsWhenUnauthenticated() + { + $gate = $this->getBasicGate()->forUser(null); + + $response = $gate->allowIf(function (stdClass $user = null) { + return $user === null; + }); + + $this->assertTrue($response->allowed()); + } + + public function testAllowIfThrowsExceptionWhenFalse() + { + $this->expectException(AuthorizationException::class); + + $this->getBasicGate()->allowIf(false); + } + + public function testAllowIfThrowsExceptionWhenCallbackFalse() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $this->getBasicGate()->allowIf(function () { + return false; + }, 'foo', 'bar'); + } + + public function testAllowIfThrowsExceptionWhenResponseDenied() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $this->getBasicGate()->allowIf(Response::deny('foo', 'bar')); + } + + public function testAllowIfThrowsExceptionWhenCallbackResponseDenied() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('quz'); + $this->expectExceptionCode('qux'); + + $this->getBasicGate()->allowIf(function () { + return Response::deny('quz', 'qux'); + }, 'foo', 'bar'); + } + + public function testAllowIfThrowsExceptionIfUnauthenticated() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $gate = $this->getBasicGate()->forUser(null); + + $gate->allowIf(function () { + return true; + }, 'foo', 'bar'); + } + + public function testAllowIfThrowsExceptionIfAuthUserExpectedWhenGuest() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $gate = $this->getBasicGate()->forUser(null); + + $gate->allowIf(function (stdClass $user) { + return true; + }, 'foo', 'bar'); + } + + public function testDenyIfAuthorizesFalse() + { + $response = $this->getBasicGate()->denyIf(false); + + $this->assertTrue($response->allowed()); + } + + public function testDenyIfAuthorizesFalsy() + { + $response = $this->getBasicGate()->denyIf(0); + + $this->assertTrue($response->allowed()); + } + + public function testDenyIfAuthorizesIfGuest() + { + $response = $this->getBasicGate()->forUser(null)->denyIf(false); + + $this->assertTrue($response->allowed()); + } + + public function testDenyIfAuthorizesCallbackFalse() + { + $response = $this->getBasicGate()->denyIf(function ($user) { + $this->assertSame(1, $user->id); + + return false; + }, 'foo', 'bar'); + + $this->assertTrue($response->allowed()); + $this->assertSame('foo', $response->message()); + $this->assertSame('bar', $response->code()); + } + + public function testDenyIfAuthorizesResponseAllowed() + { + $response = $this->getBasicGate()->denyIf(Response::allow('foo', 'bar')); + + $this->assertTrue($response->allowed()); + $this->assertSame('foo', $response->message()); + $this->assertSame('bar', $response->code()); + } + + public function testDenyIfAuthorizesCallbackResponseAllowed() + { + $response = $this->getBasicGate()->denyIf(function () { + return Response::allow('quz', 'qux'); + }, 'foo', 'bar'); + + $this->assertTrue($response->allowed()); + $this->assertSame('quz', $response->message()); + $this->assertSame('qux', $response->code()); + } + + public function testDenyIfCallbackAcceptsGuestsWhenAuthenticated() + { + $response = $this->getBasicGate()->denyIf(function (stdClass $user = null) { + return $user === null; + }); + + $this->assertTrue($response->allowed()); + } + + public function testDenyIfCallbackAcceptsGuestsWhenUnauthenticated() + { + $gate = $this->getBasicGate()->forUser(null); + + $response = $gate->denyIf(function (stdClass $user = null) { + return $user !== null; + }); + + $this->assertTrue($response->allowed()); + } + + public function testDenyIfThrowsExceptionWhenTrue() + { + $this->expectException(AuthorizationException::class); + + $this->getBasicGate()->denyIf(true); + } + + public function testDenyIfThrowsExceptionWhenCallbackTrue() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $this->getBasicGate()->denyIf(function () { + return true; + }, 'foo', 'bar'); + } + + public function testDenyIfThrowsExceptionWhenResponseDenied() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $this->getBasicGate()->denyIf(Response::deny('foo', 'bar')); + } + + public function testDenyIfThrowsExceptionWhenCallbackResponseDenied() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('quz'); + $this->expectExceptionCode('qux'); + + $this->getBasicGate()->denyIf(function () { + return Response::deny('quz', 'qux'); + }, 'foo', 'bar'); + } + + public function testDenyIfThrowsExceptionIfUnauthenticated() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $gate = $this->getBasicGate()->forUser(null); + + $gate->denyIf(function () { + return false; + }, 'foo', 'bar'); + } + + public function testDenyIfThrowsExceptionIfAuthUserExpectedWhenGuest() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + $this->expectExceptionCode('bar'); + + $gate = $this->getBasicGate()->forUser(null); + + $gate->denyIf(function (stdClass $user) { + return false; + }, 'foo', 'bar'); + } + protected function getBasicGate($isAdmin = false) { return new Gate(new Container, function () use ($isAdmin) { @@ -760,9 +1038,9 @@ class AuthAccessGateTest extends TestCase /** * @dataProvider hasAbilitiesTestDataProvider * - * @param array $abilitiesToSet - * @param array|string $abilitiesToCheck - * @param bool $expectedHasValue + * @param array $abilitiesToSet + * @param array|string $abilitiesToCheck + * @param bool $expectedHasValue */ public function testHasAbilities($abilitiesToSet, $abilitiesToCheck, $expectedHasValue) { diff --git a/tests/Auth/AuthDatabaseUserProviderTest.php b/tests/Auth/AuthDatabaseUserProviderTest.php index 279a90181163b34a22cef49ea37bbef61d72ce74..fd6b5be983053cd0cec95f452e29dc5223ce8e87 100755 --- a/tests/Auth/AuthDatabaseUserProviderTest.php +++ b/tests/Auth/AuthDatabaseUserProviderTest.php @@ -28,7 +28,7 @@ class AuthDatabaseUserProviderTest extends TestCase $user = $provider->retrieveById(1); $this->assertInstanceOf(GenericUser::class, $user); - $this->assertEquals(1, $user->getAuthIdentifier()); + $this->assertSame(1, $user->getAuthIdentifier()); $this->assertSame('Dayle', $user->name); } @@ -98,7 +98,27 @@ class AuthDatabaseUserProviderTest extends TestCase $user = $provider->retrieveByCredentials(['username' => 'dayle', 'password' => 'foo', 'group' => ['one', 'two']]); $this->assertInstanceOf(GenericUser::class, $user); - $this->assertEquals(1, $user->getAuthIdentifier()); + $this->assertSame(1, $user->getAuthIdentifier()); + $this->assertSame('taylor', $user->name); + } + + public function testRetrieveByCredentialsAcceptsCallback() + { + $conn = m::mock(Connection::class); + $conn->shouldReceive('table')->once()->with('foo')->andReturn($conn); + $conn->shouldReceive('where')->once()->with('username', 'dayle'); + $conn->shouldReceive('whereIn')->once()->with('group', ['one', 'two']); + $conn->shouldReceive('first')->once()->andReturn(['id' => 1, 'name' => 'taylor']); + $hasher = m::mock(Hasher::class); + $provider = new DatabaseUserProvider($conn, $hasher, 'foo'); + + $user = $provider->retrieveByCredentials([function ($builder) { + $builder->where('username', 'dayle'); + $builder->whereIn('group', ['one', 'two']); + }]); + + $this->assertInstanceOf(GenericUser::class, $user); + $this->assertSame(1, $user->getAuthIdentifier()); $this->assertSame('taylor', $user->name); } diff --git a/tests/Auth/AuthEloquentUserProviderTest.php b/tests/Auth/AuthEloquentUserProviderTest.php index 322283c266d6957d3e6acb84d9c5f20e026e51c7..ae34a1b4a0749e9789d7a28df75956a65fc66884 100755 --- a/tests/Auth/AuthEloquentUserProviderTest.php +++ b/tests/Auth/AuthEloquentUserProviderTest.php @@ -64,7 +64,6 @@ class AuthEloquentUserProviderTest extends TestCase public function testRetrievingWithOnlyPasswordCredentialReturnsNull() { $provider = $this->getProviderMock(); - $mock = m::mock(stdClass::class); $user = $provider->retrieveByCredentials(['api_password' => 'foo']); $this->assertNull($user); @@ -101,6 +100,23 @@ class AuthEloquentUserProviderTest extends TestCase $this->assertSame('bar', $user); } + public function testRetrieveByCredentialsAcceptsCallback() + { + $provider = $this->getProviderMock(); + $mock = m::mock(stdClass::class); + $mock->shouldReceive('newQuery')->once()->andReturn($mock); + $mock->shouldReceive('where')->once()->with('username', 'dayle'); + $mock->shouldReceive('whereIn')->once()->with('group', ['one', 'two']); + $mock->shouldReceive('first')->once()->andReturn('bar'); + $provider->expects($this->once())->method('createModel')->willReturn($mock); + $user = $provider->retrieveByCredentials([function ($builder) { + $builder->where('username', 'dayle'); + $builder->whereIn('group', ['one', 'two']); + }]); + + $this->assertSame('bar', $user); + } + public function testCredentialValidation() { $hasher = m::mock(Hasher::class); @@ -126,7 +142,7 @@ class AuthEloquentUserProviderTest extends TestCase { $hasher = m::mock(Hasher::class); - return $this->getMockBuilder(EloquentUserProvider::class)->setMethods(['createModel'])->setConstructorArgs([$hasher, 'foo'])->getMock(); + return $this->getMockBuilder(EloquentUserProvider::class)->onlyMethods(['createModel'])->setConstructorArgs([$hasher, 'foo'])->getMock(); } } diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index 4399268a90ad921ea377a5ae5dc7c351e932c4b7..ffd122b76dc844202b100fdc765c386caec2ac1d 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -13,10 +13,10 @@ use Illuminate\Auth\Events\Validated; use Illuminate\Auth\SessionGuard; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider; -use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Session\Session; use Illuminate\Cookie\CookieJar; +use Illuminate\Support\Timebox; use Mockery as m; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Cookie; @@ -95,6 +95,10 @@ class AuthGuardTest extends TestCase { $guard = $this->getGuard(); $guard->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox = $guard->getTimebox(); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) { + return $callback($timebox); + }); $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); @@ -104,9 +108,12 @@ class AuthGuardTest extends TestCase public function testAttemptReturnsUserInterface() { - [$session, $provider, $request, $cookie] = $this->getMocks(); - $guard = $this->getMockBuilder(SessionGuard::class)->setMethods(['login'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $guard = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['login'])->setConstructorArgs(['default', $provider, $session, $request, $timebox])->getMock(); $guard->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) { + return $callback($timebox->shouldReceive('returnEarly')->once()->getMock()); + }); $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); $events->shouldReceive('dispatch')->once()->with(m::type(Validated::class)); $user = $this->createMock(Authenticatable::class); @@ -120,6 +127,10 @@ class AuthGuardTest extends TestCase { $mock = $this->getGuard(); $mock->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox = $mock->getTimebox(); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) { + return $callback($timebox); + }); $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); @@ -127,10 +138,55 @@ class AuthGuardTest extends TestCase $this->assertFalse($mock->attempt(['foo'])); } + public function testAttemptAndWithCallbacks() + { + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request, $timebox])->getMock(); + $mock->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox->shouldReceive('call')->andReturnUsing(function ($callback) use ($timebox) { + return $callback($timebox->shouldReceive('returnEarly')->getMock()); + }); + $user = m::mock(Authenticatable::class); + $events->shouldReceive('dispatch')->times(3)->with(m::type(Attempting::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(Login::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(Authenticated::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(Validated::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(Failed::class)); + $mock->expects($this->once())->method('getName')->willReturn('foo'); + $user->shouldReceive('getAuthIdentifier')->once()->andReturn('bar'); + $mock->getSession()->shouldReceive('put')->with('foo', 'bar')->once(); + $session->shouldReceive('migrate')->once(); + $mock->getProvider()->shouldReceive('retrieveByCredentials')->times(3)->with(['foo'])->andReturn($user); + $mock->getProvider()->shouldReceive('validateCredentials')->twice()->andReturnTrue(); + $mock->getProvider()->shouldReceive('validateCredentials')->once()->andReturnFalse(); + + $this->assertTrue($mock->attemptWhen(['foo'], function ($user, $guard) { + static::assertInstanceOf(Authenticatable::class, $user); + static::assertInstanceOf(SessionGuard::class, $guard); + + return true; + })); + + $this->assertFalse($mock->attemptWhen(['foo'], function ($user, $guard) { + static::assertInstanceOf(Authenticatable::class, $user); + static::assertInstanceOf(SessionGuard::class, $guard); + + return false; + })); + + $executed = false; + + $this->assertFalse($mock->attemptWhen(['foo'], false, function () use (&$executed) { + return $executed = true; + })); + + $this->assertFalse($executed); + } + public function testLoginStoresIdentifierInSession() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $user = m::mock(Authenticatable::class); $mock->expects($this->once())->method('getName')->willReturn('foo'); $user->shouldReceive('getAuthIdentifier')->once()->andReturn('bar'); @@ -155,7 +211,7 @@ class AuthGuardTest extends TestCase public function testLoginFiresLoginAndAuthenticatedEvents() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->setDispatcher($events = m::mock(Dispatcher::class)); $user = m::mock(Authenticatable::class); $events->shouldReceive('dispatch')->once()->with(m::type(Login::class)); @@ -171,6 +227,10 @@ class AuthGuardTest extends TestCase { $guard = $this->getGuard(); $guard->setDispatcher($events = m::mock(Dispatcher::class)); + $timebox = $guard->getTimebox(); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) { + return $callback($timebox); + }); $events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class)); $events->shouldReceive('dispatch')->once()->with(m::type(Failed::class)); $events->shouldNotReceive('dispatch')->with(m::type(Validated::class)); @@ -234,7 +294,7 @@ class AuthGuardTest extends TestCase public function testIsAuthedReturnsFalseWhenUserIsNull() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['user'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['user'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->expects($this->exactly(2))->method('user')->willReturn(null); $this->assertFalse($mock->check()); $this->assertTrue($mock->guest()); @@ -268,7 +328,7 @@ class AuthGuardTest extends TestCase public function testLogoutRemovesSessionTokenAndRememberMeCookie() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName', 'getRecallerName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName', 'getRecallerName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->setCookieJar($cookies = m::mock(CookieJar::class)); $user = m::mock(Authenticatable::class); $user->shouldReceive('getRememberToken')->once()->andReturn('a'); @@ -290,7 +350,7 @@ class AuthGuardTest extends TestCase public function testLogoutDoesNotEnqueueRememberMeCookieForDeletionIfCookieDoesntExist() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->setCookieJar($cookies = m::mock(CookieJar::class)); $user = m::mock(Authenticatable::class); $user->shouldReceive('getRememberToken')->andReturn(null); @@ -306,7 +366,7 @@ class AuthGuardTest extends TestCase public function testLogoutFiresLogoutEvent() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->expects($this->once())->method('clearUserDataFromStorage'); $mock->setDispatcher($events = m::mock(Dispatcher::class)); $user = m::mock(Authenticatable::class); @@ -320,7 +380,7 @@ class AuthGuardTest extends TestCase public function testLogoutDoesNotSetRememberTokenIfNotPreviouslySet() { [$session, $provider, $request] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $user = m::mock(Authenticatable::class); $user->shouldReceive('getRememberToken')->andReturn(null); @@ -334,7 +394,7 @@ class AuthGuardTest extends TestCase public function testLogoutCurrentDeviceRemovesRememberMeCookie() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName', 'getRecallerName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName', 'getRecallerName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->setCookieJar($cookies = m::mock(CookieJar::class)); $user = m::mock(Authenticatable::class); $mock->expects($this->once())->method('getName')->willReturn('foo'); @@ -353,7 +413,7 @@ class AuthGuardTest extends TestCase public function testLogoutCurrentDeviceDoesNotEnqueueRememberMeCookieForDeletionIfCookieDoesntExist() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['getName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName', 'recaller'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->setCookieJar($cookies = m::mock(CookieJar::class)); $user = m::mock(Authenticatable::class); $user->shouldReceive('getRememberToken')->andReturn(null); @@ -369,7 +429,7 @@ class AuthGuardTest extends TestCase public function testLogoutCurrentDeviceFiresLogoutEvent() { [$session, $provider, $request, $cookie] = $this->getMocks(); - $mock = $this->getMockBuilder(SessionGuard::class)->setMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); + $mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock(); $mock->expects($this->once())->method('clearUserDataFromStorage'); $mock->setDispatcher($events = m::mock(Dispatcher::class)); $user = m::mock(Authenticatable::class); @@ -386,7 +446,27 @@ class AuthGuardTest extends TestCase $guard = new SessionGuard('default', $provider, $session, $request); $guard->setCookieJar($cookie); $foreverCookie = new Cookie($guard->getRecallerName(), 'foo'); - $cookie->shouldReceive('forever')->once()->with($guard->getRecallerName(), 'foo|recaller|bar')->andReturn($foreverCookie); + $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|bar', 2628000)->andReturn($foreverCookie); + $cookie->shouldReceive('queue')->once()->with($foreverCookie); + $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); + $session->shouldReceive('migrate')->once(); + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn('foo'); + $user->shouldReceive('getAuthPassword')->andReturn('bar'); + $user->shouldReceive('getRememberToken')->andReturn('recaller'); + $user->shouldReceive('setRememberToken')->never(); + $provider->shouldReceive('updateRememberToken')->never(); + $guard->login($user, true); + } + + public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride() + { + [$session, $provider, $request, $cookie] = $this->getMocks(); + $guard = new SessionGuard('default', $provider, $session, $request); + $guard->setRememberDuration(5000); + $guard->setCookieJar($cookie); + $foreverCookie = new Cookie($guard->getRecallerName(), 'foo'); + $cookie->shouldReceive('make')->once()->with($guard->getRecallerName(), 'foo|recaller|bar', 5000)->andReturn($foreverCookie); $cookie->shouldReceive('queue')->once()->with($foreverCookie); $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('migrate')->once(); @@ -405,7 +485,7 @@ class AuthGuardTest extends TestCase $guard = new SessionGuard('default', $provider, $session, $request); $guard->setCookieJar($cookie); $foreverCookie = new Cookie($guard->getRecallerName(), 'foo'); - $cookie->shouldReceive('forever')->once()->andReturn($foreverCookie); + $cookie->shouldReceive('make')->once()->andReturn($foreverCookie); $cookie->shouldReceive('queue')->once()->with($foreverCookie); $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('migrate')->once(); @@ -483,9 +563,12 @@ class AuthGuardTest extends TestCase public function testLoginOnceSetsUser() { - [$session, $provider, $request, $cookie] = $this->getMocks(); - $guard = m::mock(SessionGuard::class, ['default', $provider, $session])->makePartial(); + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $guard = m::mock(SessionGuard::class, ['default', $provider, $session, $request, $timebox])->makePartial(); $user = m::mock(Authenticatable::class); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) { + return $callback($timebox->shouldReceive('returnEarly')->once()->getMock()); + }); $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user); $guard->getProvider()->shouldReceive('validateCredentials')->once()->with($user, ['foo'])->andReturn(true); $guard->shouldReceive('setUser')->once()->with($user); @@ -494,9 +577,12 @@ class AuthGuardTest extends TestCase public function testLoginOnceFailure() { - [$session, $provider, $request, $cookie] = $this->getMocks(); - $guard = m::mock(SessionGuard::class, ['default', $provider, $session])->makePartial(); + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); + $guard = m::mock(SessionGuard::class, ['default', $provider, $session, $request, $timebox])->makePartial(); $user = m::mock(Authenticatable::class); + $timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) { + return $callback($timebox); + }); $guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user); $guard->getProvider()->shouldReceive('validateCredentials')->once()->with($user, ['foo'])->andReturn(false); $this->assertFalse($guard->once(['foo'])); @@ -504,9 +590,9 @@ class AuthGuardTest extends TestCase protected function getGuard() { - [$session, $provider, $request, $cookie] = $this->getMocks(); + [$session, $provider, $request, $cookie, $timebox] = $this->getMocks(); - return new SessionGuard('default', $provider, $session, $request); + return new SessionGuard('default', $provider, $session, $request, $timebox); } protected function getMocks() @@ -516,6 +602,7 @@ class AuthGuardTest extends TestCase m::mock(UserProvider::class), Request::create('/', 'GET'), m::mock(CookieJar::class), + m::mock(Timebox::class), ]; } diff --git a/tests/Auth/AuthPasswordBrokerTest.php b/tests/Auth/AuthPasswordBrokerTest.php index 84e8523811fa00638fb2b3729b76cc8d80f780a1..f89971b4c7cf94403fd17ef88befe6651c4af670 100755 --- a/tests/Auth/AuthPasswordBrokerTest.php +++ b/tests/Auth/AuthPasswordBrokerTest.php @@ -7,7 +7,6 @@ use Illuminate\Auth\Passwords\TokenRepositoryInterface; use Illuminate\Contracts\Auth\CanResetPassword; use Illuminate\Contracts\Auth\PasswordBroker as PasswordBrokerContract; use Illuminate\Contracts\Auth\UserProvider; -use Illuminate\Contracts\Mail\Mailer; use Illuminate\Support\Arr; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -23,22 +22,25 @@ class AuthPasswordBrokerTest extends TestCase public function testIfUserIsNotFoundErrorRedirectIsReturned() { $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['getUser', 'makeErrorRedirect'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = $this->getMockBuilder(PasswordBroker::class) + ->onlyMethods(['getUser']) + ->addMethods(['makeErrorRedirect']) + ->setConstructorArgs(array_values($mocks)) + ->getMock(); $broker->expects($this->once())->method('getUser')->willReturn(null); - $this->assertEquals(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['credentials'])); + $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['credentials'])); } public function testIfTokenIsRecentlyCreated() { $mocks = $this->getMocks(); - $mocks['tokens'] = m::mock(TestTokenRepositoryInterface::class); - $broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(true); $user->shouldReceive('sendPasswordResetNotification')->with('token'); - $this->assertEquals(PasswordBrokerContract::RESET_THROTTLED, $broker->sendResetLink(['foo'])); + $this->assertSame(PasswordBrokerContract::RESET_THROTTLED, $broker->sendResetLink(['foo'])); } public function testGetUserThrowsExceptionIfUserDoesntImplementCanResetPassword() @@ -63,12 +65,13 @@ class AuthPasswordBrokerTest extends TestCase public function testBrokerCreatesTokenAndRedirectsWithoutError() { $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); + $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(false); $mocks['tokens']->shouldReceive('create')->once()->with($user)->andReturn('token'); $user->shouldReceive('sendPasswordResetNotification')->with('token'); - $this->assertEquals(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo'])); + $this->assertSame(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo'])); } public function testRedirectIsReturnedByResetWhenUserCredentialsInvalid() @@ -76,7 +79,7 @@ class AuthPasswordBrokerTest extends TestCase $broker = $this->getBroker($mocks = $this->getMocks()); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['creds'])->andReturn(null); - $this->assertEquals(PasswordBrokerContract::INVALID_USER, $broker->reset(['creds'], function () { + $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->reset(['creds'], function () { // })); } @@ -88,7 +91,7 @@ class AuthPasswordBrokerTest extends TestCase $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(Arr::except($creds, ['token']))->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('exists')->with($user, 'token')->andReturn(false); - $this->assertEquals(PasswordBrokerContract::INVALID_TOKEN, $broker->reset($creds, function () { + $this->assertSame(PasswordBrokerContract::INVALID_TOKEN, $broker->reset($creds, function () { // })); } @@ -96,7 +99,11 @@ class AuthPasswordBrokerTest extends TestCase public function testResetRemovesRecordOnReminderTableAndCallsCallback() { unset($_SERVER['__password.reset.test']); - $broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['validateReset', 'getPassword', 'getToken'])->setConstructorArgs(array_values($mocks = $this->getMocks()))->getMock(); + $broker = $this->getMockBuilder(PasswordBroker::class) + ->onlyMethods(['validateReset']) + ->addMethods(['getPassword', 'getToken']) + ->setConstructorArgs(array_values($mocks = $this->getMocks())) + ->getMock(); $broker->expects($this->once())->method('validateReset')->willReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('delete')->once()->with($user); $callback = function ($user, $password) { @@ -105,30 +112,40 @@ class AuthPasswordBrokerTest extends TestCase return 'foo'; }; - $this->assertEquals(PasswordBrokerContract::PASSWORD_RESET, $broker->reset(['password' => 'password', 'token' => 'token'], $callback)); + $this->assertSame(PasswordBrokerContract::PASSWORD_RESET, $broker->reset(['password' => 'password', 'token' => 'token'], $callback)); $this->assertEquals(['user' => $user, 'password' => 'password'], $_SERVER['__password.reset.test']); } + public function testExecutesCallbackInsteadOfSendingNotification() + { + $executed = false; + + $closure = function () use (&$executed) { + $executed = true; + }; + + $mocks = $this->getMocks(); + $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); + $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(false); + $mocks['tokens']->shouldReceive('create')->once()->with($user)->andReturn('token'); + $user->shouldReceive('sendPasswordResetNotification')->with('token'); + + $this->assertEquals(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo'], $closure)); + + $this->assertTrue($executed); + } + protected function getBroker($mocks) { - return new PasswordBroker($mocks['tokens'], $mocks['users'], $mocks['mailer'], $mocks['view']); + return new PasswordBroker($mocks['tokens'], $mocks['users']); } protected function getMocks() { return [ 'tokens' => m::mock(TokenRepositoryInterface::class), - 'users' => m::mock(UserProvider::class), - 'mailer' => m::mock(Mailer::class), - 'view' => 'resetLinkView', + 'users' => m::mock(UserProvider::class), ]; } } - -// Before 7.x we have to check the existence of a new method. In 7.x, this code must be moved to -// Illuminate\Auth\Passwords\TokenRepositoryInterface - -interface TestTokenRepositoryInterface extends TokenRepositoryInterface -{ - public function recentlyCreatedToken(CanResetPassword $user); -} diff --git a/tests/Auth/AuthTokenGuardTest.php b/tests/Auth/AuthTokenGuardTest.php index 1f0b9c80e9ff393c36a9274c059952eef2d7d975..b79c079b88857dafc85e78994a6b6553d22672f4 100644 --- a/tests/Auth/AuthTokenGuardTest.php +++ b/tests/Auth/AuthTokenGuardTest.php @@ -27,10 +27,10 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); $this->assertTrue($guard->check()); $this->assertFalse($guard->guest()); - $this->assertEquals(1, $guard->id()); + $this->assertSame(1, $guard->id()); } public function testTokenCanBeHashed() @@ -45,10 +45,10 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); $this->assertTrue($guard->check()); $this->assertFalse($guard->guest()); - $this->assertEquals(1, $guard->id()); + $this->assertSame(1, $guard->id()); } public function testUserCanBeRetrievedByAuthHeaders() @@ -61,7 +61,7 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); } public function testUserCanBeRetrievedByBearerToken() @@ -74,7 +74,7 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); } public function testValidateCanDetermineIfCredentialsAreValid() @@ -124,7 +124,7 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); } public function testUserCanBeRetrievedByBearerTokenWithCustomKey() @@ -137,7 +137,7 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); } public function testUserCanBeRetrievedByQueryStringVariableWithCustomKey() @@ -152,10 +152,10 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); $this->assertTrue($guard->check()); $this->assertFalse($guard->guest()); - $this->assertEquals(1, $guard->id()); + $this->assertSame(1, $guard->id()); } public function testUserCanBeRetrievedByAuthHeadersWithCustomField() @@ -168,7 +168,7 @@ class AuthTokenGuardTest extends TestCase $user = $guard->user(); - $this->assertEquals(1, $user->id); + $this->assertSame(1, $user->id); } public function testValidateCanDetermineIfCredentialsAreValidWithCustomKey() diff --git a/tests/Auth/AuthenticatableTest.php b/tests/Auth/AuthenticatableTest.php index 3837f06cf2bb8ae5c49d8581f9acc9aa6f79d11b..51bd662f3ccdcf6a247af5d0925dc342cd53d48f 100644 --- a/tests/Auth/AuthenticatableTest.php +++ b/tests/Auth/AuthenticatableTest.php @@ -23,7 +23,8 @@ class AuthenticatableTest extends TestCase public function testItReturnsNullWhenRememberTokenNameWasSetToEmpty() { - $user = new class extends User { + $user = new class extends User + { public function getRememberTokenName() { return ''; diff --git a/tests/Auth/AuthenticateMiddlewareTest.php b/tests/Auth/AuthenticateMiddlewareTest.php index 6b7e77d557fbc9a4783253720855254c9c3e6426..837187ec84160c217b92ed3de18bf82c7696a61a 100644 --- a/tests/Auth/AuthenticateMiddlewareTest.php +++ b/tests/Auth/AuthenticateMiddlewareTest.php @@ -58,7 +58,7 @@ class AuthenticateMiddlewareTest extends TestCase return; } - return $this->fail(); + $this->fail(); } public function testDefaultAuthenticatedKeepsDefaultDriver() @@ -109,7 +109,7 @@ class AuthenticateMiddlewareTest extends TestCase return; } - return $this->fail(); + $this->fail(); } public function testMultipleDriversAuthenticatedUpdatesDefault() @@ -178,7 +178,7 @@ class AuthenticateMiddlewareTest extends TestCase * @param string ...$guards * @return void * - * @throws AuthenticationException + * @throws \Illuminate\Auth\AuthenticationException */ protected function authenticate(...$guards) { diff --git a/tests/Auth/AuthorizeMiddlewareTest.php b/tests/Auth/AuthorizeMiddlewareTest.php index ef9a7be2542114d7a668af597f3e38d5275f33ac..83b9adcd85f18bc79c197d129e36ea46cf22813f 100644 --- a/tests/Auth/AuthorizeMiddlewareTest.php +++ b/tests/Auth/AuthorizeMiddlewareTest.php @@ -87,7 +87,7 @@ class AuthorizeMiddlewareTest extends TestCase $response = $this->router->dispatch(Request::create('dashboard', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testSimpleAbilityWithStringParameter() @@ -105,7 +105,7 @@ class AuthorizeMiddlewareTest extends TestCase $response = $this->router->dispatch(Request::create('dashboard', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testSimpleAbilityWithNullParameter() @@ -154,10 +154,10 @@ class AuthorizeMiddlewareTest extends TestCase ]); $response = $this->router->dispatch(Request::create('posts/1/comments', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); $response = $this->router->dispatch(Request::create('comments', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testSimpleAbilityWithStringParameterFromRouteParameter() @@ -175,7 +175,7 @@ class AuthorizeMiddlewareTest extends TestCase $response = $this->router->dispatch(Request::create('dashboard/true', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testModelTypeUnauthorized() @@ -184,7 +184,7 @@ class AuthorizeMiddlewareTest extends TestCase $this->expectExceptionMessage('This action is unauthorized.'); $this->gate()->define('create', function ($user, $model) { - $this->assertEquals($model, 'App\User'); + $this->assertSame('App\User', $model); return false; }); @@ -202,7 +202,7 @@ class AuthorizeMiddlewareTest extends TestCase public function testModelTypeAuthorized() { $this->gate()->define('create', function ($user, $model) { - $this->assertEquals($model, 'App\User'); + $this->assertSame('App\User', $model); return true; }); @@ -216,7 +216,7 @@ class AuthorizeMiddlewareTest extends TestCase $response = $this->router->dispatch(Request::create('users/create', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testModelUnauthorized() @@ -269,7 +269,7 @@ class AuthorizeMiddlewareTest extends TestCase $response = $this->router->dispatch(Request::create('posts/1/edit', 'GET')); - $this->assertEquals($response->content(), 'success'); + $this->assertSame('success', $response->content()); } public function testModelInstanceAsParameter() diff --git a/tests/Auth/AuthorizesResourcesTest.php b/tests/Auth/AuthorizesResourcesTest.php index b5ef1bdf9df3b2a68573228add43a20ad3136f02..f05d94f49809a5a3cdabefadc0cb951343e7766d 100644 --- a/tests/Auth/AuthorizesResourcesTest.php +++ b/tests/Auth/AuthorizesResourcesTest.php @@ -17,6 +17,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'create', 'can:create,App\User'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'create', 'can:create,App\User,App\Post'); } public function testStoreMethod() @@ -24,6 +28,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'store', 'can:create,App\User'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'store', 'can:create,App\User,App\Post'); } public function testShowMethod() @@ -31,6 +39,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'show', 'can:view,user'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'show', 'can:view,user,post'); } public function testEditMethod() @@ -38,6 +50,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'edit', 'can:update,user'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'edit', 'can:update,user,post'); } public function testUpdateMethod() @@ -45,6 +61,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'update', 'can:update,user'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'update', 'can:update,user,post'); } public function testDestroyMethod() @@ -52,6 +72,10 @@ class AuthorizesResourcesTest extends TestCase $controller = new AuthorizesResourcesController; $this->assertHasMiddleware($controller, 'destroy', 'can:delete,user'); + + $controller = new AuthorizesResourcesWithArrayController; + + $this->assertHasMiddleware($controller, 'destroy', 'can:delete,user,post'); } /** @@ -67,7 +91,7 @@ class AuthorizesResourcesTest extends TestCase $router = new Router(new Dispatcher); $router->aliasMiddleware('can', AuthorizesResourcesMiddleware::class); - $router->get($method)->uses(AuthorizesResourcesController::class.'@'.$method); + $router->get($method)->uses(get_class($controller).'@'.$method); $this->assertSame( 'caught '.$middleware, @@ -122,10 +146,57 @@ class AuthorizesResourcesController extends Controller } } +class AuthorizesResourcesWithArrayController extends Controller +{ + use AuthorizesRequests; + + public function __construct() + { + $this->authorizeResource(['App\User', 'App\Post'], ['user', 'post']); + } + + public function index() + { + // + } + + public function create() + { + // + } + + public function store() + { + // + } + + public function show() + { + // + } + + public function edit() + { + // + } + + public function update() + { + // + } + + public function destroy() + { + // + } +} + class AuthorizesResourcesMiddleware { - public function handle($request, Closure $next, $method, $parameter) + public function handle($request, Closure $next, $method, $parameter, ...$models) { - return "caught can:{$method},{$parameter}"; + $params = array_merge([$parameter], $models); + + return "caught can:{$method},".implode(',', $params); } } diff --git a/tests/Broadcasting/AblyBroadcasterTest.php b/tests/Broadcasting/AblyBroadcasterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..74683de0a27bec2b5b7018ceac127783bab79daf --- /dev/null +++ b/tests/Broadcasting/AblyBroadcasterTest.php @@ -0,0 +1,151 @@ +<?php + +namespace Illuminate\Tests\Broadcasting; + +use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster; +use Illuminate\Http\Request; +use Mockery as m; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +class AblyBroadcasterTest extends TestCase +{ + /** + * @var \Illuminate\Broadcasting\Broadcasters\AblyBroadcaster + */ + public $broadcaster; + + public $ably; + + protected function setUp(): void + { + parent::setUp(); + + $this->ably = m::mock('Ably\AblyRest'); + $this->ably->options = (object) ['key' => 'abcd:efgh']; + + $this->broadcaster = m::mock(AblyBroadcaster::class, [$this->ably])->makePartial(); + } + + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + { + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return false; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('private-test') + ); + } + + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + { + $returnData = [1, 2, 3, 4]; + $this->broadcaster->channel('test', function () use ($returnData) { + return $returnData; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + // + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return [1, 2, 3, 4]; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('presence-test') + ); + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->channel_name = $channel; + $request->socket_id = 'abcd.1234'; + + $request->shouldReceive('input') + ->with('callback', false) + ->andReturn(false); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifierForBroadcasting') + ->andReturn(42); + $user->shouldReceive('getAuthIdentifier') + ->andReturn(42); + + $request->shouldReceive('user') + ->andReturn($user); + + return $request; + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithoutUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->channel_name = $channel; + + $request->shouldReceive('user') + ->andReturn(null); + + return $request; + } +} diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php index 02d6b3983d425f053197a85d2abb0ac90443b1bf..5b591406383a597204af981cf66ecf058941e7d9 100644 --- a/tests/Broadcasting/BroadcastEventTest.php +++ b/tests/Broadcasting/BroadcastEventTest.php @@ -3,7 +3,9 @@ namespace Illuminate\Tests\Broadcasting; use Illuminate\Broadcasting\BroadcastEvent; +use Illuminate\Broadcasting\InteractsWithBroadcasting; use Illuminate\Contracts\Broadcasting\Broadcaster; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -22,9 +24,13 @@ class BroadcastEventTest extends TestCase ['test-channel'], TestBroadcastEvent::class, ['firstName' => 'Taylor', 'lastName' => 'Otwell', 'collection' => ['foo' => 'bar']] ); + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + $event = new TestBroadcastEvent; - (new BroadcastEvent($event))->handle($broadcaster); + (new BroadcastEvent($event))->handle($manager); } public function testManualParameterSpecification() @@ -35,9 +41,28 @@ class BroadcastEventTest extends TestCase ['test-channel'], TestBroadcastEventWithManualData::class, ['name' => 'Taylor', 'socket' => null] ); + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + $event = new TestBroadcastEventWithManualData; - (new BroadcastEvent($event))->handle($broadcaster); + (new BroadcastEvent($event))->handle($manager); + } + + public function testSpecificBroadcasterGiven() + { + $broadcaster = m::mock(Broadcaster::class); + + $broadcaster->shouldReceive('broadcast')->once(); + + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with('log')->andReturn($broadcaster); + + $event = new TestBroadcastEventWithSpecificBroadcaster; + + (new BroadcastEvent($event))->handle($manager); } } @@ -66,3 +91,13 @@ class TestBroadcastEventWithManualData extends TestBroadcastEvent return ['name' => 'Taylor']; } } + +class TestBroadcastEventWithSpecificBroadcaster extends TestBroadcastEvent +{ + use InteractsWithBroadcasting; + + public function __construct() + { + $this->broadcastVia('log'); + } +} diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php index 18159f479fd7df69466c8ab520fa30482160e8ec..cb5349227fef5f7a31563b7fc2a62a9e00337e3c 100644 --- a/tests/Broadcasting/PusherBroadcasterTest.php +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -161,6 +161,8 @@ class PusherBroadcasterTest extends TestCase ->andReturn(false); $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifierForBroadcasting') + ->andReturn(42); $user->shouldReceive('getAuthIdentifier') ->andReturn(42); diff --git a/tests/Broadcasting/RedisBroadcasterTest.php b/tests/Broadcasting/RedisBroadcasterTest.php index d381188b87e0203ef3017a94c3b155ecac415646..3345c7c26ef5fe28b796465197d16853db30b5b8 100644 --- a/tests/Broadcasting/RedisBroadcasterTest.php +++ b/tests/Broadcasting/RedisBroadcasterTest.php @@ -170,6 +170,8 @@ class RedisBroadcasterTest extends TestCase $request->channel_name = $channel; $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifierForBroadcasting') + ->andReturn(42); $user->shouldReceive('getAuthIdentifier') ->andReturn(42); diff --git a/tests/Broadcasting/UsePusherChannelsNamesTest.php b/tests/Broadcasting/UsePusherChannelsNamesTest.php index c8124f561aa1790adf7380f92564140ac0016ba2..b07dbb2fcc6a30d725f8b8455c7577b9e53bfe82 100644 --- a/tests/Broadcasting/UsePusherChannelsNamesTest.php +++ b/tests/Broadcasting/UsePusherChannelsNamesTest.php @@ -6,14 +6,14 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster; use Illuminate\Broadcasting\Broadcasters\UsePusherChannelConventions; use PHPUnit\Framework\TestCase; -class UsePusherChannelConventionsTest extends TestCase +class UsePusherChannelsNamesTest extends TestCase { /** * @dataProvider channelsProvider */ public function testChannelNameNormalization($requestChannelName, $normalizedName) { - $broadcaster = new FakeBroadcasterUsingPusherChannelsNames(); + $broadcaster = new FakeBroadcasterUsingPusherChannelsNames; $this->assertSame( $normalizedName, @@ -23,7 +23,7 @@ class UsePusherChannelConventionsTest extends TestCase public function testChannelNameNormalizationSpecialCase() { - $broadcaster = new FakeBroadcasterUsingPusherChannelsNames(); + $broadcaster = new FakeBroadcasterUsingPusherChannelsNames; $this->assertSame( 'private-123', @@ -36,7 +36,7 @@ class UsePusherChannelConventionsTest extends TestCase */ public function testIsGuardedChannel($requestChannelName, $_, $guarded) { - $broadcaster = new FakeBroadcasterUsingPusherChannelsNames(); + $broadcaster = new FakeBroadcasterUsingPusherChannelsNames; $this->assertSame( $guarded, diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5f8a3c1a24f6b0fd3b66f4351cfcc9fca5963ea9 --- /dev/null +++ b/tests/Bus/BusBatchTest.php @@ -0,0 +1,474 @@ +<?php + +namespace Illuminate\Tests\Bus; + +use Carbon\CarbonImmutable; +use Illuminate\Bus\Batch; +use Illuminate\Bus\Batchable; +use Illuminate\Bus\BatchFactory; +use Illuminate\Bus\DatabaseBatchRepository; +use Illuminate\Bus\PendingBatch; +use Illuminate\Bus\Queueable; +use Illuminate\Container\Container; +use Illuminate\Contracts\Queue\Factory; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\PostgresConnection; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\CallQueuedClosure; +use Mockery as m; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use stdClass; + +class BusBatchTest extends TestCase +{ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + + $_SERVER['__finally.count'] = 0; + $_SERVER['__then.count'] = 0; + $_SERVER['__catch.count'] = 0; + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('job_batches', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->text('failed_job_ids'); + $table->text('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + unset($_SERVER['__finally.batch'], $_SERVER['__then.batch'], $_SERVER['__catch.batch'], $_SERVER['__catch.exception']); + + $this->schema()->drop('job_batches'); + + m::close(); + } + + public function test_jobs_can_be_added_to_the_batch() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $job = new class + { + use Batchable; + }; + + $secondJob = new class + { + use Batchable; + }; + + $thirdJob = function () { + }; + + $queue->shouldReceive('connection')->once() + ->with('test-connection') + ->andReturn($connection = m::mock(stdClass::class)); + + $connection->shouldReceive('bulk')->once()->with(m::on(function ($args) use ($job, $secondJob) { + return + $args[0] == $job && + $args[1] == $secondJob && + $args[2] instanceof CallQueuedClosure + && is_string($args[2]->batchId); + }), '', 'test-queue'); + + $batch = $batch->add([$job, $secondJob, $thirdJob]); + + $this->assertEquals(3, $batch->totalJobs); + $this->assertEquals(3, $batch->pendingJobs); + $this->assertIsString($job->batchId); + $this->assertInstanceOf(CarbonImmutable::class, $batch->createdAt); + } + + public function test_processed_jobs_can_be_calculated() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $batch->totalJobs = 10; + $batch->pendingJobs = 4; + + $this->assertEquals(6, $batch->processedJobs()); + $this->assertEquals(60, $batch->progress()); + } + + public function test_successful_jobs_can_be_recorded() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $job = new class + { + use Batchable; + }; + + $secondJob = new class + { + use Batchable; + }; + + $queue->shouldReceive('connection')->once() + ->with('test-connection') + ->andReturn($connection = m::mock(stdClass::class)); + + $connection->shouldReceive('bulk')->once(); + + $batch = $batch->add([$job, $secondJob]); + $this->assertEquals(2, $batch->pendingJobs); + + $batch->recordSuccessfulJob('test-id'); + $batch->recordSuccessfulJob('test-id'); + + $this->assertInstanceOf(Batch::class, $_SERVER['__finally.batch']); + $this->assertInstanceOf(Batch::class, $_SERVER['__then.batch']); + + $batch = $batch->fresh(); + $this->assertEquals(0, $batch->pendingJobs); + $this->assertTrue($batch->finished()); + $this->assertEquals(1, $_SERVER['__finally.count']); + $this->assertEquals(1, $_SERVER['__then.count']); + } + + public function test_failed_jobs_can_be_recorded_while_not_allowing_failures() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue, $allowFailures = false); + + $job = new class + { + use Batchable; + }; + + $secondJob = new class + { + use Batchable; + }; + + $queue->shouldReceive('connection')->once() + ->with('test-connection') + ->andReturn($connection = m::mock(stdClass::class)); + + $connection->shouldReceive('bulk')->once(); + + $batch = $batch->add([$job, $secondJob]); + $this->assertEquals(2, $batch->pendingJobs); + + $batch->recordFailedJob('test-id', new RuntimeException('Something went wrong.')); + $batch->recordFailedJob('test-id', new RuntimeException('Something else went wrong.')); + + $this->assertInstanceOf(Batch::class, $_SERVER['__finally.batch']); + $this->assertFalse(isset($_SERVER['__then.batch'])); + + $batch = $batch->fresh(); + $this->assertEquals(2, $batch->pendingJobs); + $this->assertEquals(2, $batch->failedJobs); + $this->assertTrue($batch->finished()); + $this->assertTrue($batch->cancelled()); + $this->assertEquals(1, $_SERVER['__finally.count']); + $this->assertEquals(1, $_SERVER['__catch.count']); + $this->assertSame('Something went wrong.', $_SERVER['__catch.exception']->getMessage()); + } + + public function test_failed_jobs_can_be_recorded_while_allowing_failures() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue, $allowFailures = true); + + $job = new class + { + use Batchable; + }; + + $secondJob = new class + { + use Batchable; + }; + + $queue->shouldReceive('connection')->once() + ->with('test-connection') + ->andReturn($connection = m::mock(stdClass::class)); + + $connection->shouldReceive('bulk')->once(); + + $batch = $batch->add([$job, $secondJob]); + $this->assertEquals(2, $batch->pendingJobs); + + $batch->recordFailedJob('test-id', new RuntimeException('Something went wrong.')); + $batch->recordFailedJob('test-id', new RuntimeException('Something else went wrong.')); + + // While allowing failures this batch never actually completes... + $this->assertFalse(isset($_SERVER['__then.batch'])); + + $batch = $batch->fresh(); + $this->assertEquals(2, $batch->pendingJobs); + $this->assertEquals(2, $batch->failedJobs); + $this->assertFalse($batch->finished()); + $this->assertFalse($batch->cancelled()); + $this->assertEquals(1, $_SERVER['__catch.count']); + $this->assertSame('Something went wrong.', $_SERVER['__catch.exception']->getMessage()); + } + + public function test_batch_can_be_cancelled() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $batch->cancel(); + + $batch = $batch->fresh(); + + $this->assertTrue($batch->cancelled()); + } + + public function test_batch_can_be_deleted() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $batch->delete(); + + $batch = $batch->fresh(); + + $this->assertNull($batch); + } + + public function test_batch_state_can_be_inspected() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $this->assertFalse($batch->finished()); + $batch->finishedAt = now(); + $this->assertTrue($batch->finished()); + + $batch->options['then'] = []; + $this->assertFalse($batch->hasThenCallbacks()); + $batch->options['then'] = [1]; + $this->assertTrue($batch->hasThenCallbacks()); + + $this->assertFalse($batch->allowsFailures()); + $batch->options['allowFailures'] = true; + $this->assertTrue($batch->allowsFailures()); + + $this->assertFalse($batch->hasFailures()); + $batch->failedJobs = 1; + $this->assertTrue($batch->hasFailures()); + + $batch->options['catch'] = []; + $this->assertFalse($batch->hasCatchCallbacks()); + $batch->options['catch'] = [1]; + $this->assertTrue($batch->hasCatchCallbacks()); + + $this->assertFalse($batch->cancelled()); + $batch->cancelledAt = now(); + $this->assertTrue($batch->cancelled()); + + $this->assertIsString(json_encode($batch)); + } + + public function test_chain_can_be_added_to_batch() + { + $queue = m::mock(Factory::class); + + $batch = $this->createTestBatch($queue); + + $chainHeadJob = new ChainHeadJob; + + $secondJob = new SecondTestJob; + + $thirdJob = new ThirdTestJob; + + $queue->shouldReceive('connection')->once() + ->with('test-connection') + ->andReturn($connection = m::mock(stdClass::class)); + + $connection->shouldReceive('bulk')->once()->with(m::on(function ($args) use ($chainHeadJob, $secondJob, $thirdJob) { + return + $args[0] == $chainHeadJob + && serialize($secondJob) == $args[0]->chained[0] + && serialize($thirdJob) == $args[0]->chained[1]; + }), '', 'test-queue'); + + $batch = $batch->add([ + [$chainHeadJob, $secondJob, $thirdJob], + ]); + + $this->assertEquals(3, $batch->totalJobs); + $this->assertEquals(3, $batch->pendingJobs); + $this->assertSame('test-queue', $chainHeadJob->chainQueue); + $this->assertIsString($chainHeadJob->batchId); + $this->assertIsString($secondJob->batchId); + $this->assertIsString($thirdJob->batchId); + $this->assertInstanceOf(CarbonImmutable::class, $batch->createdAt); + } + + public function test_options_serialization_on_postgres() + { + $pendingBatch = (new PendingBatch(new Container, collect())) + ->onQueue('test-queue'); + + $connection = m::spy(PostgresConnection::class); + + $connection->shouldReceive('table')->andReturnSelf() + ->shouldReceive('where')->andReturnSelf(); + + $repository = new DatabaseBatchRepository( + new BatchFactory(m::mock(Factory::class)), $connection, 'job_batches' + ); + + $repository->store($pendingBatch); + + $connection->shouldHaveReceived('insert') + ->withArgs(function ($argument) use ($pendingBatch) { + return unserialize(base64_decode($argument['options'])) === $pendingBatch->options; + }); + } + + /** + * @dataProvider serializedOptions + */ + public function test_options_unserialize_on_postgres($serialize, $options) + { + $factory = m::mock(BatchFactory::class); + + $connection = m::spy(PostgresConnection::class); + + $connection->shouldReceive('table->where->first') + ->andReturn($m = (object) [ + 'id' => '', + 'name' => '', + 'total_jobs' => '', + 'pending_jobs' => '', + 'failed_jobs' => '', + 'failed_job_ids' => '[]', + 'options' => $serialize, + 'created_at' => now()->timestamp, + 'cancelled_at' => null, + 'finished_at' => null, + ]); + + $batch = (new DatabaseBatchRepository($factory, $connection, 'job_batches')); + + $factory->shouldReceive('make') + ->withSomeOfArgs($batch, '', '', '', '', '', '', $options); + + $batch->find(1); + } + + /** + * @return array + */ + public function serializedOptions() + { + $options = [1, 2]; + + return [ + [serialize($options), $options], + [base64_encode(serialize($options)), $options], + ]; + } + + protected function createTestBatch($queue, $allowFailures = false) + { + $repository = new DatabaseBatchRepository(new BatchFactory($queue), DB::connection(), 'job_batches'); + + $pendingBatch = (new PendingBatch(new Container, collect())) + ->then(function (Batch $batch) { + $_SERVER['__then.batch'] = $batch; + $_SERVER['__then.count']++; + }) + ->catch(function (Batch $batch, $e) { + $_SERVER['__catch.batch'] = $batch; + $_SERVER['__catch.exception'] = $e; + $_SERVER['__catch.count']++; + }) + ->finally(function (Batch $batch) { + $_SERVER['__finally.batch'] = $batch; + $_SERVER['__finally.count']++; + }) + ->allowFailures($allowFailures) + ->onConnection('test-connection') + ->onQueue('test-queue'); + + return $repository->store($pendingBatch); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Model::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class ChainHeadJob implements ShouldQueue +{ + use Dispatchable, Queueable, Batchable; +} + +class SecondTestJob implements ShouldQueue +{ + use Dispatchable, Queueable, Batchable; +} + +class ThirdTestJob implements ShouldQueue +{ + use Dispatchable, Queueable, Batchable; +} diff --git a/tests/Bus/BusBatchableTest.php b/tests/Bus/BusBatchableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..92e682bc2fcb5904c2825877a9927b3bb8e9bcf5 --- /dev/null +++ b/tests/Bus/BusBatchableTest.php @@ -0,0 +1,38 @@ +<?php + +namespace Illuminate\Tests\Bus; + +use Illuminate\Bus\Batchable; +use Illuminate\Bus\BatchRepository; +use Illuminate\Container\Container; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class BusBatchableTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function test_batch_may_be_retrieved() + { + $class = new class + { + use Batchable; + }; + + $this->assertSame($class, $class->withBatchId('test-batch-id')); + $this->assertSame('test-batch-id', $class->batchId); + + Container::setInstance($container = new Container); + + $repository = m::mock(BatchRepository::class); + $repository->shouldReceive('find')->once()->with('test-batch-id')->andReturn('test-batch'); + $container->instance(BatchRepository::class, $repository); + + $this->assertSame('test-batch', $class->batch()); + + Container::setInstance(null); + } +} diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php new file mode 100644 index 0000000000000000000000000000000000000000..889b3a5d482d406678f0fe43b60a3c1f750b1594 --- /dev/null +++ b/tests/Bus/BusPendingBatchTest.php @@ -0,0 +1,90 @@ +<?php + +namespace Illuminate\Tests\Bus; + +use Illuminate\Bus\Batch; +use Illuminate\Bus\Batchable; +use Illuminate\Bus\BatchRepository; +use Illuminate\Bus\PendingBatch; +use Illuminate\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Support\Collection; +use Mockery as m; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use stdClass; + +class BusPendingBatchTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function test_pending_batch_may_be_configured_and_dispatched() + { + $container = new Container; + + $eventDispatcher = m::mock(Dispatcher::class); + $eventDispatcher->shouldReceive('dispatch')->once(); + + $container->instance(Dispatcher::class, $eventDispatcher); + + $job = new class + { + use Batchable; + }; + + $pendingBatch = new PendingBatch($container, new Collection([$job])); + + $pendingBatch = $pendingBatch->then(function () { + // + })->catch(function () { + // + })->allowFailures()->onConnection('test-connection')->onQueue('test-queue')->withOption('extra-option', 123); + + $this->assertSame('test-connection', $pendingBatch->connection()); + $this->assertSame('test-queue', $pendingBatch->queue()); + $this->assertCount(1, $pendingBatch->thenCallbacks()); + $this->assertCount(1, $pendingBatch->catchCallbacks()); + $this->assertArrayHasKey('extra-option', $pendingBatch->options); + $this->assertSame(123, $pendingBatch->options['extra-option']); + + $repository = m::mock(BatchRepository::class); + $repository->shouldReceive('store')->once()->with($pendingBatch)->andReturn($batch = m::mock(stdClass::class)); + $batch->shouldReceive('add')->once()->with(m::type(Collection::class))->andReturn($batch = m::mock(Batch::class)); + + $container->instance(BatchRepository::class, $repository); + + $pendingBatch->dispatch(); + } + + public function test_batch_is_deleted_from_storage_if_exception_thrown_during_batching() + { + $this->expectException(RuntimeException::class); + + $container = new Container; + + $job = new class + { + }; + + $pendingBatch = new PendingBatch($container, new Collection([$job])); + + $repository = m::mock(BatchRepository::class); + + $repository->shouldReceive('store')->once()->with($pendingBatch)->andReturn($batch = m::mock(stdClass::class)); + + $batch->id = 'test-id'; + + $batch->shouldReceive('add')->once()->andReturnUsing(function () { + throw new RuntimeException('Failed to add jobs...'); + }); + + $repository->shouldReceive('delete')->once()->with('test-id'); + + $container->instance(BatchRepository::class, $repository); + + $pendingBatch->dispatch(); + } +} diff --git a/tests/Cache/CacheApcStoreTest.php b/tests/Cache/CacheApcStoreTest.php index e104520acdc6dc349c75e57fad5cc1000657190f..d9f7e137bfb8cd41e476e321d1150f5b9ef80156 100755 --- a/tests/Cache/CacheApcStoreTest.php +++ b/tests/Cache/CacheApcStoreTest.php @@ -10,7 +10,7 @@ class CacheApcStoreTest extends TestCase { public function testGetReturnsNullWhenNotFound() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['get'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['get'])->getMock(); $apc->expects($this->once())->method('get')->with($this->equalTo('foobar'))->willReturn(null); $store = new ApcStore($apc, 'foo'); $this->assertNull($store->get('bar')); @@ -18,7 +18,7 @@ class CacheApcStoreTest extends TestCase public function testAPCValueIsReturned() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['get'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['get'])->getMock(); $apc->expects($this->once())->method('get')->willReturn('bar'); $store = new ApcStore($apc); $this->assertSame('bar', $store->get('foo')); @@ -26,7 +26,7 @@ class CacheApcStoreTest extends TestCase public function testGetMultipleReturnsNullWhenNotFoundAndValueWhenFound() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['get'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['get'])->getMock(); $apc->expects($this->exactly(3))->method('get')->willReturnMap([ ['foo', 'qux'], ['bar', null], @@ -34,15 +34,15 @@ class CacheApcStoreTest extends TestCase ]); $store = new ApcStore($apc); $this->assertEquals([ - 'foo' => 'qux', - 'bar' => null, - 'baz' => 'norf', + 'foo' => 'qux', + 'bar' => null, + 'baz' => 'norf', ], $store->many(['foo', 'bar', 'baz'])); } public function testSetMethodProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['put'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['put'])->getMock(); $apc->expects($this->once()) ->method('put')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(60)) ->willReturn(true); @@ -53,7 +53,7 @@ class CacheApcStoreTest extends TestCase public function testSetMultipleMethodProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['put'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['put'])->getMock(); $apc->expects($this->exactly(3))->method('put')->withConsecutive([ $this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(60), ], [ @@ -63,16 +63,16 @@ class CacheApcStoreTest extends TestCase ])->willReturn(true); $store = new ApcStore($apc); $result = $store->putMany([ - 'foo' => 'bar', - 'baz' => 'qux', - 'bar' => 'norf', + 'foo' => 'bar', + 'baz' => 'qux', + 'bar' => 'norf', ], 60); $this->assertTrue($result); } public function testIncrementMethodProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['increment'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['increment'])->getMock(); $apc->expects($this->once())->method('increment')->with($this->equalTo('foo'), $this->equalTo(5)); $store = new ApcStore($apc); $store->increment('foo', 5); @@ -80,7 +80,7 @@ class CacheApcStoreTest extends TestCase public function testDecrementMethodProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['decrement'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['decrement'])->getMock(); $apc->expects($this->once())->method('decrement')->with($this->equalTo('foo'), $this->equalTo(5)); $store = new ApcStore($apc); $store->decrement('foo', 5); @@ -88,7 +88,7 @@ class CacheApcStoreTest extends TestCase public function testStoreItemForeverProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['put'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['put'])->getMock(); $apc->expects($this->once()) ->method('put')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(0)) ->willReturn(true); @@ -99,7 +99,7 @@ class CacheApcStoreTest extends TestCase public function testForgetMethodProperlyCallsAPC() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['delete'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['delete'])->getMock(); $apc->expects($this->once())->method('delete')->with($this->equalTo('foo'))->willReturn(true); $store = new ApcStore($apc); $result = $store->forget('foo'); @@ -108,7 +108,7 @@ class CacheApcStoreTest extends TestCase public function testFlushesCached() { - $apc = $this->getMockBuilder(ApcWrapper::class)->setMethods(['flush'])->getMock(); + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['flush'])->getMock(); $apc->expects($this->once())->method('flush')->willReturn(true); $store = new ApcStore($apc); $result = $store->flush(); diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index d8533b3bf0c7d3fb39f2e5ec3174fd07d7238398..f273f3ad747cca9bdbe29d294b4f33d893d59b55 100755 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Cache; use Illuminate\Cache\ArrayStore; use Illuminate\Support\Carbon; use PHPUnit\Framework\TestCase; +use stdClass; class CacheArrayStoreTest extends TestCase { @@ -21,20 +22,20 @@ class CacheArrayStoreTest extends TestCase $store = new ArrayStore; $result = $store->put('foo', 'bar', 10); $resultMany = $store->putMany([ - 'fizz' => 'buz', - 'quz' => 'baz', + 'fizz' => 'buz', + 'quz' => 'baz', ], 10); $this->assertTrue($result); $this->assertTrue($resultMany); $this->assertEquals([ - 'foo' => 'bar', - 'fizz' => 'buz', - 'quz' => 'baz', - 'norf' => null, + 'foo' => 'bar', + 'fizz' => 'buz', + 'quz' => 'baz', + 'norf' => null, ], $store->many(['foo', 'fizz', 'quz', 'norf'])); } - public function testItemsCanExpire(): void + public function testItemsCanExpire() { Carbon::setTestNow(Carbon::now()); $store = new ArrayStore; @@ -49,7 +50,7 @@ class CacheArrayStoreTest extends TestCase public function testStoreItemForeverProperlyStoresInArray() { - $mock = $this->getMockBuilder(ArrayStore::class)->setMethods(['put'])->getMock(); + $mock = $this->getMockBuilder(ArrayStore::class)->onlyMethods(['put'])->getMock(); $mock->expects($this->once()) ->method('put')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(0)) ->willReturn(true); @@ -74,6 +75,19 @@ class CacheArrayStoreTest extends TestCase $this->assertEquals(1, $store->get('foo')); } + public function testExpiredKeysAreIncrementedLikeNonExistingKeys() + { + Carbon::setTestNow(Carbon::now()); + $store = new ArrayStore; + + $store->put('foo', 999, 10); + Carbon::setTestNow(Carbon::now()->addSeconds(10)->addSecond()); + $result = $store->increment('foo'); + + $this->assertEquals(1, $result); + Carbon::setTestNow(null); + } + public function testValuesCanBeDecremented() { $store = new ArrayStore; @@ -182,6 +196,30 @@ class CacheArrayStoreTest extends TestCase $this->assertTrue($wannabeOwner->acquire()); } + public function testValuesAreNotStoredByReference() + { + $store = new ArrayStore($serialize = true); + $object = new stdClass; + $object->foo = true; + + $store->put('object', $object, 10); + $object->bar = true; + + $this->assertObjectNotHasAttribute('bar', $store->get('object')); + } + + public function testValuesAreStoredByReferenceIfSerializationIsDisabled() + { + $store = new ArrayStore; + $object = new stdClass; + $object->foo = true; + + $store->put('object', $object, 10); + $object->bar = true; + + $this->assertObjectHasAttribute('bar', $store->get('object')); + } + public function testReleasingLockAfterAlreadyForceReleasedByAnotherOwnerFails() { $store = new ArrayStore; diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index 36bf1a67e52aee770b859635ffa76c2aeb0fe3ee..ac98021eb87cea8173aefcad29e26ed9a875d0ed 100755 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -31,7 +31,7 @@ class CacheDatabaseStoreTest extends TestCase public function testNullIsReturnedAndItemDeletedWhenItemIsExpired() { - $store = $this->getMockBuilder(DatabaseStore::class)->setMethods(['forget'])->setConstructorArgs($this->getMocks())->getMock(); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['forget'])->setConstructorArgs($this->getMocks())->getMock(); $table = m::mock(stdClass::class); $store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table); $table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table); @@ -65,7 +65,7 @@ class CacheDatabaseStoreTest extends TestCase public function testValueIsInsertedWhenNoExceptionsAreThrown() { - $store = $this->getMockBuilder(DatabaseStore::class)->setMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); $table = m::mock(stdClass::class); $store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table); $store->expects($this->once())->method('getTime')->willReturn(1); @@ -77,7 +77,7 @@ class CacheDatabaseStoreTest extends TestCase public function testValueIsUpdatedWhenInsertThrowsException() { - $store = $this->getMockBuilder(DatabaseStore::class)->setMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); $table = m::mock(stdClass::class); $store->getConnection()->shouldReceive('table')->with('table')->andReturn($table); $store->expects($this->once())->method('getTime')->willReturn(1); @@ -93,7 +93,7 @@ class CacheDatabaseStoreTest extends TestCase public function testValueIsInsertedOnPostgres() { - $store = $this->getMockBuilder(DatabaseStore::class)->setMethods(['getTime'])->setConstructorArgs($this->getPostgresMocks())->getMock(); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getPostgresMocks())->getMock(); $table = m::mock(stdClass::class); $store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table); $store->expects($this->once())->method('getTime')->willReturn(1); @@ -105,7 +105,7 @@ class CacheDatabaseStoreTest extends TestCase public function testForeverCallsStoreItemWithReallyLongTime() { - $store = $this->getMockBuilder(DatabaseStore::class)->setMethods(['put'])->setConstructorArgs($this->getMocks())->getMock(); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['put'])->setConstructorArgs($this->getMocks())->getMock(); $store->expects($this->once())->method('put')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(315360000))->willReturn(true); $result = $store->forever('foo', 'bar'); $this->assertTrue($result); diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index aad8b7dd21ad8ecfeff4d1f56c406381a81faff9..8320237645b5acb78e408e04ce1009188fe30e06 100755 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -34,6 +34,21 @@ class CacheFileStoreTest extends TestCase $this->assertNull($value); } + public function testUnserializableFileContentGetDeleted() + { + $files = $this->mockFilesystem(); + $hash = sha1('foo'); + $cachePath = __DIR__.'/'.substr($hash, 0, 2).'/'.substr($hash, 2, 2).'/'.$hash; + + $files->expects($this->once())->method('get')->willReturn('9999999999-I_am_unserializableee: \(~_~)/'); + $files->expects($this->once())->method('exists')->with($this->equalTo($cachePath))->willReturn(true); + $files->expects($this->once())->method('delete')->with($this->equalTo($cachePath)); + + $value = (new FileStore($files, __DIR__))->get('foo'); + + $this->assertNull($value); + } + public function testPutCreatesMissingDirectories() { $files = $this->mockFilesystem(); @@ -47,12 +62,48 @@ class CacheFileStoreTest extends TestCase $this->assertTrue($result); } - public function testExpiredItemsReturnNull() + public function testPutWillConsiderZeroAsEternalTime() + { + $files = $this->mockFilesystem(); + + $hash = sha1('O--L / key'); + $filePath = __DIR__.'/'.substr($hash, 0, 2).'/'.substr($hash, 2, 2).'/'.$hash; + $ten9s = '9999999999'; // The "forever" time value. + $fileContents = $ten9s.serialize('gold'); + $exclusiveLock = true; + + $files->expects($this->once())->method('put')->with( + $this->equalTo($filePath), + $this->equalTo($fileContents), + $this->equalTo($exclusiveLock) // Ensure we do lock the file while putting. + )->willReturn(strlen($fileContents)); + + (new FileStore($files, __DIR__))->put('O--L / key', 'gold', 0); + } + + public function testPutWillConsiderBigValuesAsEternalTime() + { + $files = $this->mockFilesystem(); + + $hash = sha1('O--L / key'); + $filePath = __DIR__.'/'.substr($hash, 0, 2).'/'.substr($hash, 2, 2).'/'.$hash; + $ten9s = '9999999999'; // The "forever" time value. + $fileContents = $ten9s.serialize('gold'); + + $files->expects($this->once())->method('put')->with( + $this->equalTo($filePath), + $this->equalTo($fileContents), + ); + + (new FileStore($files, __DIR__))->put('O--L / key', 'gold', (int) $ten9s + 1); + } + + public function testExpiredItemsReturnNullAndGetDeleted() { $files = $this->mockFilesystem(); $contents = '0000000000'; $files->expects($this->once())->method('get')->willReturn($contents); - $store = $this->getMockBuilder(FileStore::class)->setMethods(['forget'])->setConstructorArgs([$files, __DIR__])->getMock(); + $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['forget'])->setConstructorArgs([$files, __DIR__])->getMock(); $store->expects($this->once())->method('forget'); $value = $store->get('foo'); $this->assertNull($value); @@ -70,7 +121,7 @@ class CacheFileStoreTest extends TestCase public function testStoreItemProperlyStoresValues() { $files = $this->mockFilesystem(); - $store = $this->getMockBuilder(FileStore::class)->setMethods(['expiration'])->setConstructorArgs([$files, __DIR__])->getMock(); + $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['expiration'])->setConstructorArgs([$files, __DIR__])->getMock(); $store->expects($this->once())->method('expiration')->with($this->equalTo(10))->willReturn(1111111111); $contents = '1111111111'.serialize('Hello World'); $hash = sha1('foo'); @@ -84,7 +135,7 @@ class CacheFileStoreTest extends TestCase { $files = m::mock(Filesystem::class); $files->shouldIgnoreMissing(); - $store = $this->getMockBuilder(FileStore::class)->setMethods(['expiration'])->setConstructorArgs([$files, __DIR__, 0644])->getMock(); + $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['expiration'])->setConstructorArgs([$files, __DIR__, 0644])->getMock(); $hash = sha1('foo'); $cache_dir = substr($hash, 0, 2).'/'.substr($hash, 2, 2); $files->shouldReceive('put')->withArgs([__DIR__.'/'.$cache_dir.'/'.$hash, m::any(), m::any()])->andReturnUsing(function ($name, $value) { @@ -101,6 +152,31 @@ class CacheFileStoreTest extends TestCase m::close(); } + public function testStoreItemDirectoryProperlySetsPermissions() + { + $files = m::mock(Filesystem::class); + $files->shouldIgnoreMissing(); + $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['expiration'])->setConstructorArgs([$files, __DIR__, 0606])->getMock(); + $hash = sha1('foo'); + $cache_parent_dir = substr($hash, 0, 2); + $cache_dir = $cache_parent_dir.'/'.substr($hash, 2, 2); + + $files->shouldReceive('put')->withArgs([__DIR__.'/'.$cache_dir.'/'.$hash, m::any(), m::any()])->andReturnUsing(function ($name, $value) { + return strlen($value); + }); + + $files->shouldReceive('exists')->withArgs([__DIR__.'/'.$cache_dir])->andReturn(false)->once(); + $files->shouldReceive('makeDirectory')->withArgs([__DIR__.'/'.$cache_dir, 0777, true, true])->once(); + $files->shouldReceive('chmod')->withArgs([__DIR__.'/'.$cache_parent_dir])->andReturn(['0600'])->once(); + $files->shouldReceive('chmod')->withArgs([__DIR__.'/'.$cache_parent_dir, 0606])->andReturn([true])->once(); + $files->shouldReceive('chmod')->withArgs([__DIR__.'/'.$cache_dir])->andReturn(['0600'])->once(); + $files->shouldReceive('chmod')->withArgs([__DIR__.'/'.$cache_dir, 0606])->andReturn([true])->once(); + + $result = $store->put('foo', 'foo', 10); + $this->assertTrue($result); + m::close(); + } + public function testForeversAreStoredWithHighTimestamp() { $files = $this->mockFilesystem(); @@ -124,6 +200,19 @@ class CacheFileStoreTest extends TestCase $this->assertSame('Hello World', $store->get('foo')); } + public function testIncrementCanAtomicallyJump() + { + $files = $this->mockFilesystem(); + $initialValue = '9999999999'.serialize(1); + $valueAfterIncrement = '9999999999'.serialize(4); + $store = new FileStore($files, __DIR__); + $files->expects($this->once())->method('get')->willReturn($initialValue); + $hash = sha1('foo'); + $cache_dir = substr($hash, 0, 2).'/'.substr($hash, 2, 2); + $files->expects($this->once())->method('put')->with($this->equalTo(__DIR__.'/'.$cache_dir.'/'.$hash), $this->equalTo($valueAfterIncrement)); + $store->increment('foo', 3); + } + public function testIncrementDoesNotExtendCacheLife() { $files = $this->mockFilesystem(); diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 8a8d3446d173fef5859d51b3f4fe40d73953de5d..dde234b01248ffad6aee6bd6d5d3412e57be646a 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -39,7 +39,7 @@ class CacheManagerTest extends TestCase $cacheManager->shouldReceive('resolve') ->withArgs(['array']) ->times(4) - ->andReturn(new ArrayStore()); + ->andReturn(new ArrayStore); $cacheManager->shouldReceive('getDefaultDriver') ->once() @@ -64,7 +64,7 @@ class CacheManagerTest extends TestCase ], ]); $cacheManager->extend('forget', function () { - return new ArrayStore(); + return new ArrayStore; }); $cacheManager->store('forget')->forever('foo', 'bar'); diff --git a/tests/Cache/CacheMemcachedConnectorTest.php b/tests/Cache/CacheMemcachedConnectorTest.php index dcb76a7983e9c5cf156d346f7cc86d48636f65ef..402b210b0dbed7621f87f1e3fdff516a279bede1 100755 --- a/tests/Cache/CacheMemcachedConnectorTest.php +++ b/tests/Cache/CacheMemcachedConnectorTest.php @@ -6,12 +6,15 @@ use Illuminate\Cache\MemcachedConnector; use Memcached; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class CacheMemcachedConnectorTest extends TestCase { protected function tearDown(): void { m::close(); + + parent::tearDown(); } public function testServersAreAddedCorrectly() @@ -45,12 +48,11 @@ class CacheMemcachedConnectorTest extends TestCase $this->assertSame($result, $memcached); } + /** + * @requires extension memcached + */ public function testServersAreAddedCorrectlyWithValidOptions() { - if (! class_exists('Memcached')) { - $this->markTestSkipped('Memcached module not installed'); - } - $validOptions = [ Memcached::OPT_NO_BLOCK => true, Memcached::OPT_CONNECT_TIMEOUT => 2000, @@ -69,12 +71,11 @@ class CacheMemcachedConnectorTest extends TestCase $this->assertSame($result, $memcached); } + /** + * @requires extension memcached + */ public function testServersAreAddedCorrectlyWithSaslCredentials() { - if (! class_exists('Memcached')) { - $this->markTestSkipped('Memcached module not installed'); - } - $saslCredentials = ['foo', 'bar']; $memcached = $this->memcachedMockWithAddServer(); @@ -102,7 +103,7 @@ class CacheMemcachedConnectorTest extends TestCase protected function connectorMock() { - return $this->getMockBuilder(MemcachedConnector::class)->setMethods(['createMemcachedInstance'])->getMock(); + return $this->getMockBuilder(MemcachedConnector::class)->onlyMethods(['createMemcachedInstance'])->getMock(); } protected function connect( diff --git a/tests/Cache/CacheMemcachedStoreTest.php b/tests/Cache/CacheMemcachedStoreTest.php index f65637e967b3c665230db47f0cebda52895992de..b4bf580880b2bbff71f70a2d02eee493003a0e4a 100755 --- a/tests/Cache/CacheMemcachedStoreTest.php +++ b/tests/Cache/CacheMemcachedStoreTest.php @@ -9,9 +9,12 @@ use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; +/** + * @requires extension memcached + */ class CacheMemcachedStoreTest extends TestCase { - public function tearDown(): void + protected function tearDown(): void { m::close(); @@ -20,11 +23,7 @@ class CacheMemcachedStoreTest extends TestCase public function testGetReturnsNullWhenNotFound() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(stdClass::class)->setMethods(['get', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['get', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('get')->with($this->equalTo('foo:bar'))->willReturn(null); $memcache->expects($this->once())->method('getResultCode')->willReturn(1); $store = new MemcachedStore($memcache, 'foo'); @@ -33,11 +32,7 @@ class CacheMemcachedStoreTest extends TestCase public function testMemcacheValueIsReturned() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(stdClass::class)->setMethods(['get', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['get', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('get')->willReturn('bar'); $memcache->expects($this->once())->method('getResultCode')->willReturn(0); $store = new MemcachedStore($memcache); @@ -46,11 +41,7 @@ class CacheMemcachedStoreTest extends TestCase public function testMemcacheGetMultiValuesAreReturnedWithCorrectKeys() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(stdClass::class)->setMethods(['getMulti', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['getMulti', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('getMulti')->with( ['foo:foo', 'foo:bar', 'foo:baz'] )->willReturn([ @@ -59,9 +50,9 @@ class CacheMemcachedStoreTest extends TestCase $memcache->expects($this->once())->method('getResultCode')->willReturn(0); $store = new MemcachedStore($memcache, 'foo'); $this->assertEquals([ - 'foo' => 'fizz', - 'bar' => 'buzz', - 'baz' => 'norf', + 'foo' => 'fizz', + 'bar' => 'buzz', + 'baz' => 'norf', ], $store->many([ 'foo', 'bar', 'baz', ])); @@ -69,12 +60,8 @@ class CacheMemcachedStoreTest extends TestCase public function testSetMethodProperlyCallsMemcache() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - Carbon::setTestNow($now = Carbon::now()); - $memcache = $this->getMockBuilder(Memcached::class)->setMethods(['set'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['set'])->getMock(); $memcache->expects($this->once())->method('set')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo($now->timestamp + 60))->willReturn(true); $store = new MemcachedStore($memcache); $result = $store->put('foo', 'bar', 60); @@ -84,10 +71,6 @@ class CacheMemcachedStoreTest extends TestCase public function testIncrementMethodProperlyCallsMemcache() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - /* @link https://github.com/php-memcached-dev/php-memcached/pull/468 */ if (version_compare(phpversion(), '8.0.0', '>=')) { $this->markTestSkipped('Test broken due to parse error in PHP Memcached.'); @@ -102,10 +85,6 @@ class CacheMemcachedStoreTest extends TestCase public function testDecrementMethodProperlyCallsMemcache() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - /* @link https://github.com/php-memcached-dev/php-memcached/pull/468 */ if (version_compare(phpversion(), '8.0.0', '>=')) { $this->markTestSkipped('Test broken due to parse error in PHP Memcached.'); @@ -120,11 +99,7 @@ class CacheMemcachedStoreTest extends TestCase public function testStoreItemForeverProperlyCallsMemcached() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(Memcached::class)->setMethods(['set'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['set'])->getMock(); $memcache->expects($this->once())->method('set')->with($this->equalTo('foo'), $this->equalTo('bar'), $this->equalTo(0))->willReturn(true); $store = new MemcachedStore($memcache); $result = $store->forever('foo', 'bar'); @@ -133,11 +108,7 @@ class CacheMemcachedStoreTest extends TestCase public function testForgetMethodProperlyCallsMemcache() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(Memcached::class)->setMethods(['delete'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['delete'])->getMock(); $memcache->expects($this->once())->method('delete')->with($this->equalTo('foo')); $store = new MemcachedStore($memcache); $store->forget('foo'); @@ -145,11 +116,7 @@ class CacheMemcachedStoreTest extends TestCase public function testFlushesCached() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - - $memcache = $this->getMockBuilder(Memcached::class)->setMethods(['flush'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['flush'])->getMock(); $memcache->expects($this->once())->method('flush')->willReturn(true); $store = new MemcachedStore($memcache); $result = $store->flush(); @@ -158,10 +125,6 @@ class CacheMemcachedStoreTest extends TestCase public function testGetAndSetPrefix() { - if (! class_exists(Memcached::class)) { - $this->markTestSkipped('Memcached module not installed'); - } - $store = new MemcachedStore(new Memcached, 'bar'); $this->assertSame('bar:', $store->getPrefix()); $store->setPrefix('foo'); diff --git a/tests/Cache/CacheNullStoreTest.php b/tests/Cache/CacheNullStoreTest.php index 5fbcf0b18160cbdcfc8f3af5be462e720d11de08..545c9621bc24d459e1bdbca56d48bd4633ea1820 100644 --- a/tests/Cache/CacheNullStoreTest.php +++ b/tests/Cache/CacheNullStoreTest.php @@ -19,8 +19,8 @@ class CacheNullStoreTest extends TestCase $store = new NullStore; $this->assertEquals([ - 'foo' => null, - 'bar' => null, + 'foo' => null, + 'bar' => null, ], $store->many([ 'foo', 'bar', diff --git a/tests/Cache/CacheRateLimiterTest.php b/tests/Cache/CacheRateLimiterTest.php index f0d9236da0cb0568f19adaca2f79a6e5aa780c33..2f7d0af576571a9d87427756011be3b2caf8995e 100644 --- a/tests/Cache/CacheRateLimiterTest.php +++ b/tests/Cache/CacheRateLimiterTest.php @@ -66,4 +66,74 @@ class CacheRateLimiterTest extends TestCase $rateLimiter->clear('key'); } + + public function testAvailableInReturnsPositiveValues() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->andReturn(now()->subSeconds(60)->getTimestamp(), null); + $rateLimiter = new RateLimiter($cache); + + $this->assertTrue($rateLimiter->availableIn('key:timer') >= 0); + $this->assertTrue($rateLimiter->availableIn('key:timer') >= 0); + } + + public function testAttemptsCallbackReturnsTrue() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(0); + $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1); + $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturns(1); + $cache->shouldReceive('increment')->once()->with('key')->andReturn(1); + + $executed = false; + + $rateLimiter = new RateLimiter($cache); + + $this->assertTrue($rateLimiter->attempt('key', 1, function () use (&$executed) { + $executed = true; + }, 1)); + $this->assertTrue($executed); + } + + public function testAttemptsCallbackReturnsCallbackReturn() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(0); + $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1); + $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturns(1); + $cache->shouldReceive('increment')->once()->with('key')->andReturn(1); + + $rateLimiter = new RateLimiter($cache); + + $this->assertEquals('foo', $rateLimiter->attempt('key', 1, function () { + return 'foo'; + }, 1)); + } + + public function testAttemptsCallbackReturnsFalse() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(2); + $cache->shouldReceive('has')->once()->with('key:timer')->andReturn(true); + + $executed = false; + + $rateLimiter = new RateLimiter($cache); + + $this->assertFalse($rateLimiter->attempt('key', 1, function () use (&$executed) { + $executed = true; + }, 1)); + $this->assertFalse($executed); + } + + public function testKeysAreSanitizedFromUnicodeCharacters() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->once()->with('john', 0)->andReturn(1); + $cache->shouldReceive('has')->once()->with('john:timer')->andReturn(true); + $cache->shouldReceive('add')->never(); + $rateLimiter = new RateLimiter($cache); + + $this->assertTrue($rateLimiter->tooManyAttempts('jôhn', 1)); + } } diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php index 6884a88bd7e6cd55dbae1eba9b1248ab4aff7c13..2ab6fce62c538c94cbe4186dfb7c55108d0d40f7 100755 --- a/tests/Cache/CacheRedisStoreTest.php +++ b/tests/Cache/CacheRedisStoreTest.php @@ -80,8 +80,8 @@ class CacheRedisStoreTest extends TestCase $connection->shouldReceive('exec')->once(); $result = $redis->putMany([ - 'foo' => 'bar', - 'baz' => 'qux', + 'foo' => 'bar', + 'baz' => 'qux', 'bar' => 'norf', ], 60); $this->assertTrue($result); diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 5567fc467b13159d158438f2d3c592255dc4aca7..9ec39d54daad69338fb093454729b93dcbca3783 100755 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -7,8 +7,10 @@ use DateInterval; use DateTime; use DateTimeImmutable; use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\FileStore; use Illuminate\Cache\RedisStore; use Illuminate\Cache\Repository; +use Illuminate\Cache\TaggableStore; use Illuminate\Container\Container; use Illuminate\Contracts\Cache\Store; use Illuminate\Events\Dispatcher; @@ -111,6 +113,19 @@ class CacheRepositoryTest extends TestCase return 'qux'; }); $this->assertSame('qux', $result); + + /* + * Use a callable... + */ + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->andReturn(null); + $repo->getStore()->shouldReceive('put')->once()->with('foo', 'bar', 10); + $result = $repo->remember('foo', function () { + return 10; + }, function () { + return 'bar'; + }); + $this->assertSame('bar', $result); } public function testRememberForeverMethodCallsForeverAndReturnsDefault() @@ -190,6 +205,36 @@ class CacheRepositoryTest extends TestCase $this->assertTrue($repository->add('k', 'v', 60)); } + public function testAddMethodCanAcceptDateIntervals() + { + $storeWithAdd = m::mock(RedisStore::class); + $storeWithAdd->shouldReceive('add')->once()->with('k', 'v', 61)->andReturn(true); + $repository = new Repository($storeWithAdd); + $this->assertTrue($repository->add('k', 'v', DateInterval::createFromDateString('61 seconds'))); + + $storeWithoutAdd = m::mock(ArrayStore::class); + $this->assertFalse(method_exists(ArrayStore::class, 'add'), 'This store should not have add method on it.'); + $storeWithoutAdd->shouldReceive('get')->once()->with('k')->andReturn(null); + $storeWithoutAdd->shouldReceive('put')->once()->with('k', 'v', 60)->andReturn(true); + $repository = new Repository($storeWithoutAdd); + $this->assertTrue($repository->add('k', 'v', DateInterval::createFromDateString('60 seconds'))); + } + + public function testAddMethodCanAcceptDateTimeInterface() + { + $withAddStore = m::mock(RedisStore::class); + $withAddStore->shouldReceive('add')->once()->with('k', 'v', 61)->andReturn(true); + $repository = new Repository($withAddStore); + $this->assertTrue($repository->add('k', 'v', Carbon::now()->addSeconds(61))); + + $noAddStore = m::mock(ArrayStore::class); + $this->assertFalse(method_exists(ArrayStore::class, 'add'), 'This store should not have add method on it.'); + $noAddStore->shouldReceive('get')->once()->with('k')->andReturn(null); + $noAddStore->shouldReceive('put')->once()->with('k', 'v', 62)->andReturn(true); + $repository = new Repository($noAddStore); + $this->assertTrue($repository->add('k', 'v', Carbon::now()->addSeconds(62))); + } + public function testAddWithNullTTLRemembersItemForever() { $repo = $this->getRepository(); @@ -206,6 +251,8 @@ class CacheRepositoryTest extends TestCase $this->assertFalse($result); $result = $repo->add('foo', 'bar', Carbon::now()); $this->assertFalse($result); + $result = $repo->add('foo', 'bar', -1); + $this->assertFalse($result); } public function dataProviderTestGetSeconds() @@ -223,7 +270,8 @@ class CacheRepositoryTest extends TestCase /** * @dataProvider dataProviderTestGetSeconds - * @param mixed $duration + * + * @param mixed $duration */ public function testGetSeconds($duration) { @@ -240,7 +288,7 @@ class CacheRepositoryTest extends TestCase $repo::macro(__CLASS__, function () { return 'Taylor'; }); - $this->assertEquals($repo->{__CLASS__}(), 'Taylor'); + $this->assertSame('Taylor', $repo->{__CLASS__}()); } public function testForgettingCacheKey() @@ -312,6 +360,22 @@ class CacheRepositoryTest extends TestCase $repo->tags('foo', 'bar', 'baz'); } + public function testTaggableRepositoriesSupportTags() + { + $taggable = m::mock(TaggableStore::class); + $taggableRepo = new Repository($taggable); + + $this->assertTrue($taggableRepo->supportsTags()); + } + + public function testNonTaggableRepositoryDoesNotSupportTags() + { + $nonTaggable = m::mock(FileStore::class); + $nonTaggableRepo = new Repository($nonTaggable); + + $this->assertFalse($nonTaggableRepo->supportsTags()); + } + protected function getRepository() { $dispatcher = new Dispatcher(m::mock(Container::class)); diff --git a/tests/Cache/CacheTableCommandTest.php b/tests/Cache/CacheTableCommandTest.php index 937b9aac91a4854befbbee29ecb8d307bb297beb..4a00a2f5999bc03260a4c40a59211276515a54ca 100644 --- a/tests/Cache/CacheTableCommandTest.php +++ b/tests/Cache/CacheTableCommandTest.php @@ -50,6 +50,6 @@ class CacheTableCommandTestStub extends CacheTableCommand { public function call($command, array $arguments = []) { - // + return 0; } } diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 2fee3e8e84de2eac8ea47ac5e9c6639908f0118b..b2493694d136716f8b97f4bd4139c43ea5d9f3d5 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -56,6 +56,131 @@ class CacheTaggedCacheTest extends TestCase $this->assertSame('bar', $store->tags('bop')->get('foo')); } + public function testWithIncrement() + { + $store = new ArrayStore; + $taggableStore = $store->tags('bop'); + + $taggableStore->put('foo', 5, 10); + + $value = $taggableStore->increment('foo'); + $this->assertSame(6, $value); + + $value = $taggableStore->increment('foo'); + $this->assertSame(7, $value); + + $value = $taggableStore->increment('foo', 3); + $this->assertSame(10, $value); + + $value = $taggableStore->increment('foo', -2); + $this->assertSame(8, $value); + + $value = $taggableStore->increment('x'); + $this->assertSame(1, $value); + + $value = $taggableStore->increment('y', 10); + $this->assertSame(10, $value); + } + + public function testWithDecrement() + { + $store = new ArrayStore; + $taggableStore = $store->tags('bop'); + + $taggableStore->put('foo', 50, 10); + + $value = $taggableStore->decrement('foo'); + $this->assertSame(49, $value); + + $value = $taggableStore->decrement('foo'); + $this->assertSame(48, $value); + + $value = $taggableStore->decrement('foo', 3); + $this->assertSame(45, $value); + + $value = $taggableStore->decrement('foo', -2); + $this->assertSame(47, $value); + + $value = $taggableStore->decrement('x'); + $this->assertSame(-1, $value); + + $value = $taggableStore->decrement('y', 10); + $this->assertSame(-10, $value); + } + + public function testMany() + { + $store = $this->getTestCacheStoreWithTagValues(); + + $values = $store->tags(['fruit'])->many(['a', 'e', 'b', 'd', 'c']); + $this->assertSame([ + 'a' => 'apple', + 'e' => null, + 'b' => 'banana', + 'd' => null, + 'c' => 'orange', + ], $values); + } + + public function testManyWithDefaultValues() + { + $store = $this->getTestCacheStoreWithTagValues(); + + $values = $store->tags(['fruit'])->many([ + 'a' => 147, + 'e' => 547, + 'b' => 'hello world!', + 'x' => 'hello world!', + 'd', + 'c', + ]); + $this->assertSame([ + 'a' => 'apple', + 'e' => 547, + 'b' => 'banana', + 'x' => 'hello world!', + 'd' => null, + 'c' => 'orange', + ], $values); + } + + public function testGetMultiple() + { + $store = $this->getTestCacheStoreWithTagValues(); + + $values = $store->tags(['fruit'])->getMultiple(['a', 'e', 'b', 'd', 'c']); + $this->assertSame([ + 'a' => 'apple', + 'e' => null, + 'b' => 'banana', + 'd' => null, + 'c' => 'orange', + ], $values); + + $values = $store->tags(['fruit', 'color'])->getMultiple(['a', 'e', 'b', 'd', 'c']); + $this->assertSame([ + 'a' => 'red', + 'e' => 'blue', + 'b' => null, + 'd' => 'yellow', + 'c' => null, + ], $values); + } + + public function testGetMultipleWithDefaultValue() + { + $store = $this->getTestCacheStoreWithTagValues(); + + $values = $store->tags(['fruit', 'color'])->getMultiple(['a', 'e', 'b', 'd', 'c'], 547); + $this->assertSame([ + 'a' => 'red', + 'e' => 'blue', + 'b' => 547, + 'd' => 'yellow', + 'c' => 547, + ], $values); + } + public function testTagsWithIncrementCanBeFlushed() { $store = new ArrayStore; @@ -142,23 +267,49 @@ class CacheTaggedCacheTest extends TestCase $store->shouldReceive('connection')->andReturn($conn = m::mock(stdClass::class)); // Forever tag keys - $conn->shouldReceive('smembers')->once()->with('prefix:foo:forever_ref')->andReturn(['key1', 'key2']); - $conn->shouldReceive('smembers')->once()->with('prefix:bar:forever_ref')->andReturn(['key3']); + $conn->shouldReceive('sscan')->once()->with('prefix:foo:forever_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key1', 'key2']]); + $conn->shouldReceive('sscan')->once()->with('prefix:bar:forever_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key3']]); $conn->shouldReceive('del')->once()->with('key1', 'key2'); $conn->shouldReceive('del')->once()->with('key3'); $conn->shouldReceive('del')->once()->with('prefix:foo:forever_ref'); $conn->shouldReceive('del')->once()->with('prefix:bar:forever_ref'); // Standard tag keys - $conn->shouldReceive('smembers')->once()->with('prefix:foo:standard_ref')->andReturn(['key4', 'key5']); - $conn->shouldReceive('smembers')->once()->with('prefix:bar:standard_ref')->andReturn(['key6']); + $conn->shouldReceive('sscan')->once()->with('prefix:foo:standard_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key4', 'key5']]); + $conn->shouldReceive('sscan')->once()->with('prefix:bar:standard_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key6']]); $conn->shouldReceive('del')->once()->with('key4', 'key5'); $conn->shouldReceive('del')->once()->with('key6'); $conn->shouldReceive('del')->once()->with('prefix:foo:standard_ref'); $conn->shouldReceive('del')->once()->with('prefix:bar:standard_ref'); - $tagSet->shouldReceive('reset')->once(); + $tagSet->shouldReceive('flush')->once(); $redis->flush(); } + + private function getTestCacheStoreWithTagValues(): ArrayStore + { + $store = new ArrayStore; + + $tags = ['fruit']; + $store->tags($tags)->put('a', 'apple', 10); + $store->tags($tags)->put('b', 'banana', 10); + $store->tags($tags)->put('c', 'orange', 10); + + $tags = ['fruit', 'color']; + $store->tags($tags)->putMany([ + 'a' => 'red', + 'd' => 'yellow', + 'e' => 'blue', + ], 10); + + $tags = ['sizes', 'shirt']; + $store->tags($tags)->putMany([ + 'a' => 'small', + 'b' => 'medium', + 'c' => 'large', + ], 10); + + return $store; + } } diff --git a/tests/Cache/ClearCommandTest.php b/tests/Cache/ClearCommandTest.php index 6a56fd6f2ee3297c0396b066a95622982557635d..3c2a2bb2345cc42001594b453c2bedd492051174 100644 --- a/tests/Cache/ClearCommandTest.php +++ b/tests/Cache/ClearCommandTest.php @@ -16,22 +16,22 @@ use Symfony\Component\Console\Output\NullOutput; class ClearCommandTest extends TestCase { /** - * @var ClearCommandTestStub + * @var \Illuminate\Tests\Cache\ClearCommandTestStub */ private $command; /** - * @var CacheManager|m\Mock + * @var \Illuminate\Cache\CacheManager|\Mockery\MockInterface */ private $cacheManager; /** - * @var Filesystem|m\Mock + * @var \Illuminate\Filesystem\Filesystem|\Mockery\MockInterface */ private $files; /** - * @var Repository|m\Mock + * @var \Illuminate\Contracts\Cache\Repository|\Mockery\MockInterface */ private $cacheRepository; @@ -150,6 +150,6 @@ class ClearCommandTestStub extends ClearCommand { public function call($command, array $arguments = []) { - // + return 0; } } diff --git a/tests/Cache/RedisCacheIntegrationTest.php b/tests/Cache/RedisCacheIntegrationTest.php index 578dc29d5d44b24be130074401d906b9a82b1397..410a02c6f82a4fbe0fccf7db07dfead1ba5c5fa5 100644 --- a/tests/Cache/RedisCacheIntegrationTest.php +++ b/tests/Cache/RedisCacheIntegrationTest.php @@ -5,7 +5,6 @@ namespace Illuminate\Tests\Cache; use Illuminate\Cache\RedisStore; use Illuminate\Cache\Repository; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; -use Mockery as m; use PHPUnit\Framework\TestCase; class RedisCacheIntegrationTest extends TestCase @@ -22,13 +21,12 @@ class RedisCacheIntegrationTest extends TestCase { parent::tearDown(); $this->tearDownRedis(); - m::close(); } /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testRedisCacheAddTwice($driver) { @@ -44,7 +42,7 @@ class RedisCacheIntegrationTest extends TestCase * * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testRedisCacheAddFalse($driver) { @@ -60,7 +58,7 @@ class RedisCacheIntegrationTest extends TestCase * * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testRedisCacheAddNull($driver) { diff --git a/tests/Config/RepositoryTest.php b/tests/Config/RepositoryTest.php index e3137da247a997d6b65880ac6440ac00ce620f55..53d3cc917a22feb313424e80d04d2ef3ddda9d55 100644 --- a/tests/Config/RepositoryTest.php +++ b/tests/Config/RepositoryTest.php @@ -143,6 +143,18 @@ class RepositoryTest extends TestCase $this->assertSame('xxx', $this->repository->get('array.2')); } + public function testPrependWithNewKey() + { + $this->repository->prepend('new_key', 'xxx'); + $this->assertSame(['xxx'], $this->repository->get('new_key')); + } + + public function testPushWithNewKey() + { + $this->repository->push('new_key', 'xxx'); + $this->assertSame(['xxx'], $this->repository->get('new_key')); + } + public function testAll() { $this->assertSame($this->config, $this->repository->all()); diff --git a/tests/Console/CommandTest.php b/tests/Console/CommandTest.php index 1f7d3058c4d9df4880a873ca6f9ee7f8866a5864..8b43ade1f78662dd44ca550c0e2114ac18ca00bf 100644 --- a/tests/Console/CommandTest.php +++ b/tests/Console/CommandTest.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Question\ChoiceQuestion; class CommandTest extends TestCase { @@ -22,13 +23,18 @@ class CommandTest extends TestCase public function testCallingClassCommandResolveCommandViaApplicationResolution() { - $command = new Command(); + $command = new class extends Command + { + public function handle() + { + } + }; $application = m::mock(Application::class); $command->setLaravel($application); $input = new ArrayInput([]); - $output = new NullOutput(); + $output = new NullOutput; $application->shouldReceive('make')->with(OutputStyle::class, ['input' => $input, 'output' => $output])->andReturn(m::mock(OutputStyle::class)); $application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(function () use ($command, $application) { @@ -48,7 +54,8 @@ class CommandTest extends TestCase public function testGettingCommandArgumentsAndOptionsByClass() { - $command = new class extends Command { + $command = new class extends Command + { public function handle() { } @@ -79,14 +86,14 @@ class CommandTest extends TestCase '--option-one' => 'test-first-option', '--option-two' => 'test-second-option', ]); - $output = new NullOutput(); + $output = new NullOutput; $command->run($input, $output); - $this->assertEquals('test-first-argument', $command->argument('argument-one')); - $this->assertequals('test-second-argument', $command->argument('argument-two')); - $this->assertEquals('test-first-option', $command->option('option-one')); - $this->assertEquals('test-second-option', $command->option('option-two')); + $this->assertSame('test-first-argument', $command->argument('argument-one')); + $this->assertSame('test-second-argument', $command->argument('argument-two')); + $this->assertSame('test-first-option', $command->option('option-one')); + $this->assertSame('test-second-option', $command->option('option-two')); } public function testTheInputSetterOverwrite() @@ -112,4 +119,30 @@ class CommandTest extends TestCase $command->info('foo'); } + + public function testChoiceIsSingleSelectByDefault() + { + $output = m::mock(OutputStyle::class); + $output->shouldReceive('askQuestion')->once()->withArgs(function (ChoiceQuestion $question) { + return $question->isMultiselect() === false; + }); + + $command = new Command; + $command->setOutput($output); + + $command->choice('Do you need further help?', ['yes', 'no']); + } + + public function testChoiceWithMultiselect() + { + $output = m::mock(OutputStyle::class); + $output->shouldReceive('askQuestion')->once()->withArgs(function (ChoiceQuestion $question) { + return $question->isMultiselect() === true; + }); + + $command = new Command; + $command->setOutput($output); + + $command->choice('Select all that apply.', ['option-1', 'option-2', 'option-3'], null, null, true); + } } diff --git a/tests/Console/ConsoleApplicationTest.php b/tests/Console/ConsoleApplicationTest.php index a34d6037941ba6687f7dde857dfbdcaacb37799c..6189a375adda34ca1216615321f843770e5285a8 100755 --- a/tests/Console/ConsoleApplicationTest.php +++ b/tests/Console/ConsoleApplicationTest.php @@ -82,7 +82,7 @@ class ConsoleApplicationTest extends TestCase $app = m::mock(ApplicationContract::class, ['version' => '6.0']); $events = m::mock(Dispatcher::class, ['dispatch' => null]); - return $this->getMockBuilder(Application::class)->setMethods($methods)->setConstructorArgs([ + return $this->getMockBuilder(Application::class)->onlyMethods($methods)->setConstructorArgs([ $app, $events, 'test-version', ])->getMock(); } diff --git a/tests/Console/ConsoleEventSchedulerTest.php b/tests/Console/ConsoleEventSchedulerTest.php index a04a95081946e033a6867b87c442c0092db8f4ac..4e8e5e2ee7071df49c256b1b89cd42a085672449 100644 --- a/tests/Console/ConsoleEventSchedulerTest.php +++ b/tests/Console/ConsoleEventSchedulerTest.php @@ -15,7 +15,7 @@ use PHPUnit\Framework\TestCase; class ConsoleEventSchedulerTest extends TestCase { /** - * @var Schedule + * @var \Illuminate\Console\Scheduling\Schedule */ private $schedule; @@ -101,9 +101,10 @@ class ConsoleEventSchedulerTest extends TestCase $events = $schedule->events(); $binary = $escape.PHP_BINARY.$escape; - $this->assertEquals($binary.' artisan queue:listen', $events[0]->command); - $this->assertEquals($binary.' artisan queue:listen --tries=3', $events[1]->command); - $this->assertEquals($binary.' artisan queue:listen --tries=3', $events[2]->command); + $artisan = $escape.'artisan'.$escape; + $this->assertEquals($binary.' '.$artisan.' queue:listen', $events[0]->command); + $this->assertEquals($binary.' '.$artisan.' queue:listen --tries=3', $events[1]->command); + $this->assertEquals($binary.' '.$artisan.' queue:listen --tries=3', $events[2]->command); } public function testCreateNewArtisanCommandUsingCommandClass() @@ -115,7 +116,23 @@ class ConsoleEventSchedulerTest extends TestCase $events = $schedule->events(); $binary = $escape.PHP_BINARY.$escape; - $this->assertEquals($binary.' artisan foo:bar --force', $events[0]->command); + $artisan = $escape.'artisan'.$escape; + $this->assertEquals($binary.' '.$artisan.' foo:bar --force', $events[0]->command); + } + + public function testItUsesCommandDescriptionAsEventDescription() + { + $schedule = $this->schedule; + $event = $schedule->command(ConsoleCommandStub::class); + $this->assertEquals('This is a description about the command', $event->description); + } + + public function testItShouldBePossibleToOverwriteTheDescription() + { + $schedule = $this->schedule; + $event = $schedule->command(ConsoleCommandStub::class) + ->description('This is an alternative description'); + $this->assertEquals('This is an alternative description', $event->description); } public function testCallCreatesNewJobWithTimezone() @@ -146,6 +163,8 @@ class ConsoleCommandStub extends Command { protected $signature = 'foo:bar'; + protected $description = 'This is a description about the command'; + protected $foo; public function __construct(FooClassStub $foo) diff --git a/tests/Console/Scheduling/CacheEventMutexTest.php b/tests/Console/Scheduling/CacheEventMutexTest.php index 7569319bbec57f601d4310ca0c1c6319cacaae65..1e770abd3031a8cad764980a55d944b6b8751291 100644 --- a/tests/Console/Scheduling/CacheEventMutexTest.php +++ b/tests/Console/Scheduling/CacheEventMutexTest.php @@ -12,12 +12,12 @@ use PHPUnit\Framework\TestCase; class CacheEventMutexTest extends TestCase { /** - * @var CacheEventMutex + * @var \Illuminate\Console\Scheduling\CacheEventMutex */ protected $cacheMutex; /** - * @var Event + * @var \Illuminate\Console\Scheduling\Event */ protected $event; diff --git a/tests/Console/Scheduling/CacheSchedulingMutexTest.php b/tests/Console/Scheduling/CacheSchedulingMutexTest.php index b6f613d9a39e20ee6a629c8471fbbf99cb58418a..257d80d9bee06678907dfbac3a60a7003aa03ca1 100644 --- a/tests/Console/Scheduling/CacheSchedulingMutexTest.php +++ b/tests/Console/Scheduling/CacheSchedulingMutexTest.php @@ -14,17 +14,17 @@ use PHPUnit\Framework\TestCase; class CacheSchedulingMutexTest extends TestCase { /** - * @var CacheSchedulingMutex + * @var \Illuminate\Console\Scheduling\CacheSchedulingMutex */ protected $cacheMutex; /** - * @var Event + * @var \Illuminate\Console\Scheduling\Event */ protected $event; /** - * @var Carbon + * @var \Illuminate\Support\Carbon */ protected $time; diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index a5b05a9dd787eeb088db61a6a6a27c5682b23187..e84392a635ccb5661b386f36081b45d7927715f4 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -12,61 +12,59 @@ class EventTest extends TestCase protected function tearDown(): void { m::close(); + + parent::tearDown(); } + /** + * @requires OS Linux|Darwin + */ public function testBuildCommandUsingUnix() { - if (windows_os()) { - $this->markTestSkipped('Skipping since operating system is Windows'); - } - $event = new Event(m::mock(EventMutex::class), 'php -i'); $this->assertSame("php -i > '/dev/null' 2>&1", $event->buildCommand()); } + /** + * @requires OS Windows + */ public function testBuildCommandUsingWindows() { - if (! windows_os()) { - $this->markTestSkipped('Skipping since operating system is not Windows'); - } - $event = new Event(m::mock(EventMutex::class), 'php -i'); $this->assertSame('php -i > "NUL" 2>&1', $event->buildCommand()); } + /** + * @requires OS Linux|Darwin + */ public function testBuildCommandInBackgroundUsingUnix() { - if (windows_os()) { - $this->markTestSkipped('Skipping since operating system is Windows'); - } - $event = new Event(m::mock(EventMutex::class), 'php -i'); $event->runInBackground(); $scheduleId = '"framework'.DIRECTORY_SEPARATOR.'schedule-eeb46c93d45e928d62aaf684d727e213b7094822"'; - $this->assertSame("(php -i > '/dev/null' 2>&1 ; '".PHP_BINARY."' artisan schedule:finish {$scheduleId} \"$?\") > '/dev/null' 2>&1 &", $event->buildCommand()); + $this->assertSame("(php -i > '/dev/null' 2>&1 ; '".PHP_BINARY."' 'artisan' schedule:finish {$scheduleId} \"$?\") > '/dev/null' 2>&1 &", $event->buildCommand()); } + /** + * @requires OS Windows + */ public function testBuildCommandInBackgroundUsingWindows() { - if (! windows_os()) { - $this->markTestSkipped('Skipping since operating system is not Windows'); - } - $event = new Event(m::mock(EventMutex::class), 'php -i'); $event->runInBackground(); $scheduleId = '"framework'.DIRECTORY_SEPARATOR.'schedule-eeb46c93d45e928d62aaf684d727e213b7094822"'; - $this->assertSame('start /b cmd /c "(php -i & "'.PHP_BINARY.'" artisan schedule:finish '.$scheduleId.' "%errorlevel%") > "NUL" 2>&1"', $event->buildCommand()); + $this->assertSame('start /b cmd /v:on /c "(php -i & "'.PHP_BINARY.'" artisan schedule:finish '.$scheduleId.' ^!ERRORLEVEL^!) > "NUL" 2>&1"', $event->buildCommand()); } public function testBuildCommandSendOutputTo() { - $quote = (DIRECTORY_SEPARATOR == '\\') ? '"' : "'"; + $quote = (DIRECTORY_SEPARATOR === '\\') ? '"' : "'"; $event = new Event(m::mock(EventMutex::class), 'php -i'); @@ -81,7 +79,7 @@ class EventTest extends TestCase public function testBuildCommandAppendOutput() { - $quote = (DIRECTORY_SEPARATOR == '\\') ? '"' : "'"; + $quote = (DIRECTORY_SEPARATOR === '\\') ? '"' : "'"; $event = new Event(m::mock(EventMutex::class), 'php -i'); diff --git a/tests/Console/Scheduling/FrequencyTest.php b/tests/Console/Scheduling/FrequencyTest.php index 7890f1321815a1cc5036fe5783ab5b2d9684b9f8..5a14c37ce2e09013e59d723960200d8ae36f333f 100644 --- a/tests/Console/Scheduling/FrequencyTest.php +++ b/tests/Console/Scheduling/FrequencyTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Console\Scheduling; use Illuminate\Console\Scheduling\Event; use Illuminate\Console\Scheduling\EventMutex; +use Illuminate\Support\Carbon; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -16,6 +17,8 @@ class FrequencyTest extends TestCase protected function setUp(): void { + Carbon::setTestNow(); + $this->event = new Event( m::mock(EventMutex::class), 'php foo' @@ -28,9 +31,15 @@ class FrequencyTest extends TestCase $this->assertSame('* * * * *', $this->event->everyMinute()->getExpression()); } - public function testEveryFiveMinutes() + public function testEveryXMinutes() { + $this->assertSame('*/2 * * * *', $this->event->everyTwoMinutes()->getExpression()); + $this->assertSame('*/3 * * * *', $this->event->everyThreeMinutes()->getExpression()); + $this->assertSame('*/4 * * * *', $this->event->everyFourMinutes()->getExpression()); $this->assertSame('*/5 * * * *', $this->event->everyFiveMinutes()->getExpression()); + $this->assertSame('*/10 * * * *', $this->event->everyTenMinutes()->getExpression()); + $this->assertSame('*/15 * * * *', $this->event->everyFifteenMinutes()->getExpression()); + $this->assertSame('0,30 * * * *', $this->event->everyThirtyMinutes()->getExpression()); } public function testDaily() @@ -38,11 +47,31 @@ class FrequencyTest extends TestCase $this->assertSame('0 0 * * *', $this->event->daily()->getExpression()); } + public function testDailyAt() + { + $this->assertSame('8 13 * * *', $this->event->dailyAt('13:08')->getExpression()); + } + public function testTwiceDaily() { $this->assertSame('0 3,15 * * *', $this->event->twiceDaily(3, 15)->getExpression()); } + public function testTwiceDailyAt() + { + $this->assertSame('5 3,15 * * *', $this->event->twiceDailyAt(3, 15, 5)->getExpression()); + } + + public function testWeekly() + { + $this->assertSame('0 0 * * 0', $this->event->weekly()->getExpression()); + } + + public function testWeeklyOn() + { + $this->assertSame('0 8 * * 1', $this->event->weeklyOn(1, '8:00')->getExpression()); + } + public function testOverrideWithHourly() { $this->assertSame('0 * * * *', $this->event->everyFiveMinutes()->hourly()->getExpression()); @@ -50,16 +79,41 @@ class FrequencyTest extends TestCase $this->assertSame('15,30,45 * * * *', $this->event->hourlyAt([15, 30, 45])->getExpression()); } + public function testHourly() + { + $this->assertSame('0 */2 * * *', $this->event->everyTwoHours()->getExpression()); + $this->assertSame('0 */3 * * *', $this->event->everyThreeHours()->getExpression()); + $this->assertSame('0 */4 * * *', $this->event->everyFourHours()->getExpression()); + $this->assertSame('0 */6 * * *', $this->event->everySixHours()->getExpression()); + } + + public function testMonthly() + { + $this->assertSame('0 0 1 * *', $this->event->monthly()->getExpression()); + } + public function testMonthlyOn() { $this->assertSame('0 15 4 * *', $this->event->monthlyOn(4, '15:00')->getExpression()); } + public function testLastDayOfMonth() + { + Carbon::setTestNow('2020-10-10 10:10:10'); + + $this->assertSame('0 0 31 * *', $this->event->lastDayOfMonth()->getExpression()); + } + public function testTwiceMonthly() { $this->assertSame('0 0 1,16 * *', $this->event->twiceMonthly(1, 16)->getExpression()); } + public function testTwiceMonthlyAtTime() + { + $this->assertSame('30 1 1,16 * *', $this->event->twiceMonthly(1, 16, '1:30')->getExpression()); + } + public function testMonthlyOnWithMinutes() { $this->assertSame('15 15 4 * *', $this->event->monthlyOn(4, '15:15')->getExpression()); @@ -80,6 +134,11 @@ class FrequencyTest extends TestCase $this->assertSame('* * * * 1-5', $this->event->weekdays()->getExpression()); } + public function testWeekends() + { + $this->assertSame('* * * * 6,0', $this->event->weekends()->getExpression()); + } + public function testSundays() { $this->assertSame('* * * * 0', $this->event->sundays()->getExpression()); @@ -120,6 +179,26 @@ class FrequencyTest extends TestCase $this->assertSame('0 0 1 1-12/3 *', $this->event->quarterly()->getExpression()); } + public function testYearly() + { + $this->assertSame('0 0 1 1 *', $this->event->yearly()->getExpression()); + } + + public function testYearlyOn() + { + $this->assertSame('8 15 5 4 *', $this->event->yearlyOn(4, 5, '15:08')->getExpression()); + } + + public function testYearlyOnMondaysOnly() + { + $this->assertSame('1 9 * 7 1', $this->event->mondays()->yearlyOn(7, '*', '09:01')->getExpression()); + } + + public function testYearlyOnTuesdaysAndDayOfMonth20() + { + $this->assertSame('1 9 20 7 2', $this->event->tuesdays()->yearlyOn(7, 20, '09:01')->getExpression()); + } + public function testFrequencyMacro() { Event::macro('everyXMinutes', function ($x) { diff --git a/tests/Container/ContainerCallTest.php b/tests/Container/ContainerCallTest.php index ffa2e3b8237922baba02526986f122892d42bf25..694ccd511fcb4fdf98a515b3f64c5cf7d42ace35 100644 --- a/tests/Container/ContainerCallTest.php +++ b/tests/Container/ContainerCallTest.php @@ -160,6 +160,29 @@ class ContainerCallTest extends TestCase $this->assertSame('taylor', $result[1]); } + public function testCallWithVariadicDependency() + { + $stub1 = new ContainerCallConcreteStub; + $stub2 = new ContainerCallConcreteStub; + + $container = new Container; + $container->bind(ContainerCallConcreteStub::class, function () use ($stub1, $stub2) { + return [ + $stub1, + $stub2, + ]; + }); + + $result = $container->call(function (stdClass $foo, ContainerCallConcreteStub ...$bar) { + return func_get_args(); + }); + + $this->assertInstanceOf(stdClass::class, $result[0]); + $this->assertInstanceOf(ContainerCallConcreteStub::class, $result[1]); + $this->assertSame($stub1, $result[1]); + $this->assertSame($stub2, $result[2]); + } + public function testCallWithCallableObject() { $container = new Container; @@ -169,6 +192,15 @@ class ContainerCallTest extends TestCase $this->assertSame('jeffrey', $result[1]); } + public function testCallWithCallableClassString() + { + $container = new Container; + $result = $container->call(ContainerCallCallableClassStringStub::class); + $this->assertInstanceOf(ContainerCallConcreteStub::class, $result[0]); + $this->assertSame('jeffrey', $result[1]); + $this->assertInstanceOf(ContainerTestCallStub::class, $result[2]); + } + public function testCallWithoutRequiredParamsThrowsException() { $this->expectException(BindingResolutionException::class); @@ -193,7 +225,7 @@ class ContainerCallTest extends TestCase $this->expectExceptionMessage('Unable to resolve dependency [Parameter #0 [ <required> $foo ]] in class Illuminate\Tests\Container\ContainerCallTest'); $container = new Container; - $foo = $container->call(function ($foo, $bar = 'default') { + $container->call(function ($foo, $bar = 'default') { return $foo; }); } @@ -242,3 +274,21 @@ class ContainerCallCallableStub return func_get_args(); } } + +class ContainerCallCallableClassStringStub +{ + public $stub; + + public $default; + + public function __construct(ContainerCallConcreteStub $stub, $default = 'jeffrey') + { + $this->stub = $stub; + $this->default = $default; + } + + public function __invoke(ContainerTestCallStub $dependency) + { + return [$this->stub, $this->default, $dependency]; + } +} diff --git a/tests/Container/ContainerResolveNonInstantiableTest.php b/tests/Container/ContainerResolveNonInstantiableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1f39322c40b89a81a37a6f168c73e5700e555b08 --- /dev/null +++ b/tests/Container/ContainerResolveNonInstantiableTest.php @@ -0,0 +1,75 @@ +<?php + +namespace Illuminate\Tests\Container; + +use Illuminate\Container\Container; +use PHPUnit\Framework\TestCase; + +class ContainerResolveNonInstantiableTest extends TestCase +{ + public function testResolvingNonInstantiableWithDefaultRemovesWiths() + { + $container = new Container; + $object = $container->make(ParentClass::class, ['i' => 42]); + + $this->assertSame(42, $object->i); + } + + public function testResolvingNonInstantiableWithVariadicRemovesWiths() + { + $container = new Container; + $parent = $container->make(VariadicParentClass::class, ['i' => 42]); + + $this->assertCount(0, $parent->child->objects); + $this->assertSame(42, $parent->i); + } +} + +interface TestInterface +{ +} + +class ParentClass +{ + /** + * @var int + */ + public $i; + + public function __construct(TestInterface $testObject = null, int $i = 0) + { + $this->i = $i; + } +} + +class VariadicParentClass +{ + /** + * @var \Illuminate\Tests\Container\ChildClass + */ + public $child; + + /** + * @var int + */ + public $i; + + public function __construct(ChildClass $child, int $i = 0) + { + $this->child = $child; + $this->i = $i; + } +} + +class ChildClass +{ + /** + * @var array + */ + public $objects; + + public function __construct(TestInterface ...$objects) + { + $this->objects = $objects; + } +} diff --git a/tests/Container/ContainerTaggingTest.php b/tests/Container/ContainerTaggingTest.php index 754c977e3c12e3e6cce064ef204fdaf08513ea1e..5cbc8ea57d91ab81e162da78c28a942d58590239 100644 --- a/tests/Container/ContainerTaggingTest.php +++ b/tests/Container/ContainerTaggingTest.php @@ -48,7 +48,7 @@ class ContainerTaggingTest extends TestCase public function testTaggedServicesAreLazyLoaded() { $container = $this->createPartialMock(Container::class, ['make']); - $container->expects($this->once())->method('make')->willReturn(new ContainerImplementationTaggedStub()); + $container->expects($this->once())->method('make')->willReturn(new ContainerImplementationTaggedStub); $container->tag(ContainerImplementationTaggedStub::class, ['foo']); $container->tag(ContainerImplementationTaggedStubTwo::class, ['foo']); diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 257aa5f07a1973f60556e08b5dfb9df6464db182..9f915b3bab3be80c56352412851f35df5a5a9796 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -5,9 +5,11 @@ namespace Illuminate\Tests\Container; use Illuminate\Container\Container; use Illuminate\Container\EntryNotFoundException; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Container\CircularDependencyException; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; use stdClass; +use TypeError; class ContainerTest extends TestCase { @@ -104,6 +106,31 @@ class ContainerTest extends TestCase $this->assertSame($firstInstantiation, $secondInstantiation); } + public function testScopedClosureResolution() + { + $container = new Container; + $container->scoped('class', function () { + return new stdClass; + }); + $firstInstantiation = $container->make('class'); + $secondInstantiation = $container->make('class'); + $this->assertSame($firstInstantiation, $secondInstantiation); + } + + public function testScopedClosureResets() + { + $container = new Container; + $container->scoped('class', function () { + return new stdClass; + }); + $firstInstantiation = $container->make('class'); + + $container->forgetScopedInstances(); + + $secondInstantiation = $container->make('class'); + $this->assertNotSame($firstInstantiation, $secondInstantiation); + } + public function testAutoConcreteResolution() { $container = new Container; @@ -120,6 +147,29 @@ class ContainerTest extends TestCase $this->assertSame($var1, $var2); } + public function testScopedConcreteResolutionResets() + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + $var1 = $container->make(ContainerConcreteStub::class); + + $container->forgetScopedInstances(); + + $var2 = $container->make(ContainerConcreteStub::class); + + $this->assertNotSame($var1, $var2); + } + + public function testBindFailsLoudlyWithInvalidArgument() + { + $this->expectException(TypeError::class); + $container = new Container; + + $concrete = new ContainerConcreteStub; + $container->bind(ContainerConcreteStub::class, $concrete); + } + public function testAbstractToConcreteResolution() { $container = new Container; @@ -198,6 +248,15 @@ class ContainerTest extends TestCase $this->assertSame($bound, $resolved); } + public function testBindingAnInstanceAsShared() + { + $container = new Container; + $bound = new stdClass; + $container->instance('foo', $bound); + $object = $container->make('foo'); + $this->assertSame($bound, $object); + } + public function testResolutionOfDefaultParameters() { $container = new Container; @@ -380,7 +439,7 @@ class ContainerTest extends TestCase { $container = new Container; $container->alias('ConcreteStub', 'foo'); - $this->assertEquals($container->getAlias('foo'), 'ConcreteStub'); + $this->assertSame('ConcreteStub', $container->getAlias('foo')); } public function testItThrowsExceptionWhenAbstractIsSameAsAlias() @@ -406,7 +465,7 @@ class ContainerTest extends TestCase public function testMakeWithMethodIsAnAliasForMakeMethod() { $mock = $this->getMockBuilder(Container::class) - ->setMethods(['make']) + ->onlyMethods(['make']) ->getMock(); $mock->expects($this->once()) @@ -544,6 +603,38 @@ class ContainerTest extends TestCase $this->assertInstanceOf(ContainerConcreteStub::class, $class); } + + // public function testContainerCanCatchCircularDependency() + // { + // $this->expectException(CircularDependencyException::class); + + // $container = new Container; + // $container->get(CircularAStub::class); + // } +} + +class CircularAStub +{ + public function __construct(CircularBStub $b) + { + // + } +} + +class CircularBStub +{ + public function __construct(CircularCStub $c) + { + // + } +} + +class CircularCStub +{ + public function __construct(CircularAStub $a) + { + // + } } class ContainerConcreteStub diff --git a/tests/Container/ContextualBindingTest.php b/tests/Container/ContextualBindingTest.php index a8654c7955780d8dd06dec95cf463dbcab4547ad..026a22f2ab82b270ad6f8936056f4005a85ee1c4 100644 --- a/tests/Container/ContextualBindingTest.php +++ b/tests/Container/ContextualBindingTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Container; +use Illuminate\Config\Repository; use Illuminate\Container\Container; use PHPUnit\Framework\TestCase; @@ -231,6 +232,255 @@ class ContextualBindingTest extends TestCase ); $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->implTwo->impl); } + + public function testContextualBindingWorksForVariadicDependencies() + { + $container = new Container; + + $container->when(ContainerTestContextInjectVariadic::class)->needs(IContainerContextContractStub::class)->give(function ($c) { + return [ + $c->make(ContainerContextImplementationStub::class), + $c->make(ContainerContextImplementationStubTwo::class), + ]; + }); + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadic::class); + + $this->assertCount(2, $resolvedInstance->stubs); + $this->assertInstanceOf(ContainerContextImplementationStub::class, $resolvedInstance->stubs[0]); + $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->stubs[1]); + } + + public function testContextualBindingWorksForVariadicDependenciesWithNothingBound() + { + $container = new Container; + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadic::class); + + $this->assertCount(0, $resolvedInstance->stubs); + } + + public function testContextualBindingWorksForVariadicAfterNonVariadicDependencies() + { + $container = new Container; + + $container->when(ContainerTestContextInjectVariadicAfterNonVariadic::class)->needs(IContainerContextContractStub::class)->give(function ($c) { + return [ + $c->make(ContainerContextImplementationStub::class), + $c->make(ContainerContextImplementationStubTwo::class), + ]; + }); + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadicAfterNonVariadic::class); + + $this->assertCount(2, $resolvedInstance->stubs); + $this->assertInstanceOf(ContainerContextImplementationStub::class, $resolvedInstance->stubs[0]); + $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->stubs[1]); + } + + public function testContextualBindingWorksForVariadicAfterNonVariadicDependenciesWithNothingBound() + { + $container = new Container; + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadicAfterNonVariadic::class); + + $this->assertCount(0, $resolvedInstance->stubs); + } + + public function testContextualBindingWorksForVariadicDependenciesWithoutFactory() + { + $container = new Container; + + $container->when(ContainerTestContextInjectVariadic::class)->needs(IContainerContextContractStub::class)->give([ + ContainerContextImplementationStub::class, + ContainerContextImplementationStubTwo::class, + ]); + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadic::class); + + $this->assertCount(2, $resolvedInstance->stubs); + $this->assertInstanceOf(ContainerContextImplementationStub::class, $resolvedInstance->stubs[0]); + $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->stubs[1]); + } + + public function testContextualBindingGivesTagsForArrayWithNoTagsDefined() + { + $container = new Container; + + $container->when(ContainerTestContextInjectArray::class)->needs('$stubs')->giveTagged('stub'); + + $resolvedInstance = $container->make(ContainerTestContextInjectArray::class); + + $this->assertCount(0, $resolvedInstance->stubs); + } + + public function testContextualBindingGivesTagsForVariadicWithNoTagsDefined() + { + $container = new Container; + + $container->when(ContainerTestContextInjectVariadic::class)->needs(IContainerContextContractStub::class)->giveTagged('stub'); + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadic::class); + + $this->assertCount(0, $resolvedInstance->stubs); + } + + public function testContextualBindingGivesTagsForArray() + { + $container = new Container; + + $container->tag([ + ContainerContextImplementationStub::class, + ContainerContextImplementationStubTwo::class, + ], ['stub']); + + $container->when(ContainerTestContextInjectArray::class)->needs('$stubs')->giveTagged('stub'); + + $resolvedInstance = $container->make(ContainerTestContextInjectArray::class); + + $this->assertCount(2, $resolvedInstance->stubs); + $this->assertInstanceOf(ContainerContextImplementationStub::class, $resolvedInstance->stubs[0]); + $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->stubs[1]); + } + + public function testContextualBindingGivesTagsForVariadic() + { + $container = new Container; + + $container->tag([ + ContainerContextImplementationStub::class, + ContainerContextImplementationStubTwo::class, + ], ['stub']); + + $container->when(ContainerTestContextInjectVariadic::class)->needs(IContainerContextContractStub::class)->giveTagged('stub'); + + $resolvedInstance = $container->make(ContainerTestContextInjectVariadic::class); + + $this->assertCount(2, $resolvedInstance->stubs); + $this->assertInstanceOf(ContainerContextImplementationStub::class, $resolvedInstance->stubs[0]); + $this->assertInstanceOf(ContainerContextImplementationStubTwo::class, $resolvedInstance->stubs[1]); + } + + public function testContextualBindingGivesValuesFromConfigOptionalValueNull() + { + $container = new Container; + + $container->singleton('config', function () { + return new Repository([ + 'test' => [ + 'username' => 'laravel', + 'password' => 'hunter42', + ], + ]); + }); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$username') + ->giveConfig('test.username'); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$password') + ->giveConfig('test.password'); + + $resolvedInstance = $container->make(ContainerTestContextInjectFromConfigIndividualValues::class); + + $this->assertSame('laravel', $resolvedInstance->username); + $this->assertSame('hunter42', $resolvedInstance->password); + $this->assertNull($resolvedInstance->alias); + } + + public function testContextualBindingGivesValuesFromConfigOptionalValueSet() + { + $container = new Container; + + $container->singleton('config', function () { + return new Repository([ + 'test' => [ + 'username' => 'laravel', + 'password' => 'hunter42', + 'alias' => 'lumen', + ], + ]); + }); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$username') + ->giveConfig('test.username'); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$password') + ->giveConfig('test.password'); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$alias') + ->giveConfig('test.alias'); + + $resolvedInstance = $container->make(ContainerTestContextInjectFromConfigIndividualValues::class); + + $this->assertSame('laravel', $resolvedInstance->username); + $this->assertSame('hunter42', $resolvedInstance->password); + $this->assertSame('lumen', $resolvedInstance->alias); + } + + public function testContextualBindingGivesValuesFromConfigWithDefault() + { + $container = new Container; + + $container->singleton('config', function () { + return new Repository([ + 'test' => [ + 'password' => 'hunter42', + ], + ]); + }); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$username') + ->giveConfig('test.username', 'DEFAULT_USERNAME'); + + $container + ->when(ContainerTestContextInjectFromConfigIndividualValues::class) + ->needs('$password') + ->giveConfig('test.password'); + + $resolvedInstance = $container->make(ContainerTestContextInjectFromConfigIndividualValues::class); + + $this->assertSame('DEFAULT_USERNAME', $resolvedInstance->username); + $this->assertSame('hunter42', $resolvedInstance->password); + $this->assertNull($resolvedInstance->alias); + } + + public function testContextualBindingGivesValuesFromConfigArray() + { + $container = new Container; + + $container->singleton('config', function () { + return new Repository([ + 'test' => [ + 'username' => 'laravel', + 'password' => 'hunter42', + 'alias' => 'lumen', + ], + ]); + }); + + $container + ->when(ContainerTestContextInjectFromConfigArray::class) + ->needs('$settings') + ->giveConfig('test'); + + $resolvedInstance = $container->make(ContainerTestContextInjectFromConfigArray::class); + + $this->assertSame('laravel', $resolvedInstance->settings['username']); + $this->assertSame('hunter42', $resolvedInstance->settings['password']); + $this->assertSame('lumen', $resolvedInstance->settings['alias']); + } } interface IContainerContextContractStub @@ -238,6 +488,11 @@ interface IContainerContextContractStub // } +class ContainerContextNonContractStub +{ + // +} + class ContainerContextImplementationStub implements IContainerContextContractStub { // @@ -309,3 +564,59 @@ class ContainerTestContextWithOptionalInnerDependency $this->inner = $inner; } } + +class ContainerTestContextInjectArray +{ + public $stubs; + + public function __construct(array $stubs) + { + $this->stubs = $stubs; + } +} + +class ContainerTestContextInjectVariadic +{ + public $stubs; + + public function __construct(IContainerContextContractStub ...$stubs) + { + $this->stubs = $stubs; + } +} + +class ContainerTestContextInjectVariadicAfterNonVariadic +{ + public $other; + public $stubs; + + public function __construct(ContainerContextNonContractStub $other, IContainerContextContractStub ...$stubs) + { + $this->other = $other; + $this->stubs = $stubs; + } +} + +class ContainerTestContextInjectFromConfigIndividualValues +{ + public $username; + public $password; + public $alias = null; + + public function __construct($username, $password, $alias = null) + { + $this->username = $username; + $this->password = $password; + $this->alias = $alias; + } +} + +class ContainerTestContextInjectFromConfigArray +{ + public $settings; + + public function __construct($settings) + { + $this->settings = $settings; + } +} diff --git a/tests/Container/ResolvingCallbackTest.php b/tests/Container/ResolvingCallbackTest.php index c38eec52fa76647db500922e69d9accd35d284ed..91a749710388cdce43e5e6229af1c650af8394fd 100644 --- a/tests/Container/ResolvingCallbackTest.php +++ b/tests/Container/ResolvingCallbackTest.php @@ -303,7 +303,7 @@ class ResolvingCallbackTest extends TestCase $this->assertEquals(3, $callCounter); $container->bind(ResolvingContractStub::class, function () { - return new ResolvingImplementationStubTwo(); + return new ResolvingImplementationStubTwo; }); $this->assertEquals(4, $callCounter); @@ -441,6 +441,45 @@ class ResolvingCallbackTest extends TestCase $container->make(ResolvingContractStub::class); $this->assertEquals(2, $callCounter); } + + public function testBeforeResolvingCallbacksAreCalled() + { + // Given a call counter initialized to zero. + $container = new Container; + $callCounter = 0; + + // And a contract/implementation stub binding. + $container->bind(ResolvingContractStub::class, ResolvingImplementationStub::class); + + // When we add a before resolving callback that increment the counter by one. + $container->beforeResolving(ResolvingContractStub::class, function () use (&$callCounter) { + $callCounter++; + }); + + // Then resolving the implementation stub increases the counter by one. + $container->make(ResolvingImplementationStub::class); + $this->assertEquals(1, $callCounter); + + // And resolving the contract stub increases the counter by one. + $container->make(ResolvingContractStub::class); + $this->assertEquals(2, $callCounter); + } + + public function testGlobalBeforeResolvingCallbacksAreCalled() + { + // Given a call counter initialized to zero. + $container = new Container; + $callCounter = 0; + + // When we add a global before resolving callback that increment that counter by one. + $container->beforeResolving(function () use (&$callCounter) { + $callCounter++; + }); + + // Then resolving anything increases the counter by one. + $container->make(ResolvingImplementationStub::class); + $this->assertEquals(1, $callCounter); + } } interface ResolvingContractStub diff --git a/tests/Container/RewindableGeneratorTest.php b/tests/Container/RewindableGeneratorTest.php index 7c26b173c51c387f3136be70cdeef921bbfa1088..bc1f18ca84d0418f7ec6f46de0d57b3549cbb940 100644 --- a/tests/Container/RewindableGeneratorTest.php +++ b/tests/Container/RewindableGeneratorTest.php @@ -13,7 +13,7 @@ class RewindableGeneratorTest extends TestCase yield 'foo'; }, 999); - $this->assertSame(999, count($generator)); + $this->assertCount(999, $generator); } public function testCountUsesProvidedValueAsCallback() diff --git a/tests/Cookie/CookieTest.php b/tests/Cookie/CookieTest.php index e81db62a83be613c3f31f3ea5b73d50a4182b404..06e1559ed48298158c18ec484b07f32af5c7990a 100755 --- a/tests/Cookie/CookieTest.php +++ b/tests/Cookie/CookieTest.php @@ -3,18 +3,12 @@ namespace Illuminate\Tests\Cookie; use Illuminate\Cookie\CookieJar; -use Mockery as m; use PHPUnit\Framework\TestCase; use ReflectionObject; use Symfony\Component\HttpFoundation\Cookie; class CookieTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testCookiesAreCreatedWithProperOptions() { $cookie = $this->getCreator(); @@ -118,6 +112,22 @@ class CookieTest extends TestCase $this->assertFalse($cookieJar->hasQueued('foo', '/wrongPath')); } + public function testExpire() + { + $cookieJar = $this->getCreator(); + $this->assertCount(0, $cookieJar->getQueuedCookies()); + + $cookieJar->expire('foobar', '/path', '/domain'); + + $cookie = $cookieJar->queued('foobar'); + $this->assertEquals('foobar', $cookie->getName()); + $this->assertEquals(null, $cookie->getValue()); + $this->assertEquals('/path', $cookie->getPath()); + $this->assertEquals('/domain', $cookie->getDomain()); + $this->assertTrue($cookie->getExpiresTime() < time()); + $this->assertCount(1, $cookieJar->getQueuedCookies()); + } + public function testUnqueue() { $cookie = $this->getCreator(); @@ -188,6 +198,17 @@ class CookieTest extends TestCase ); } + public function testFlushQueuedCookies(): void + { + $cookieJar = $this->getCreator(); + $cookieJar->queue($cookieJar->make('foo', 'bar', 0, '/path')); + $cookieJar->queue($cookieJar->make('foo', 'rab', 0, '/')); + $this->assertCount(2, $cookieJar->getQueuedCookies()); + + $cookieJar->flushQueuedCookies(); + $this->assertEmpty($cookieJar->getQueuedCookies()); + } + public function getCreator() { return new CookieJar; diff --git a/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php b/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php index 335fa4e9beabb34cc391d55aa8cb406093bbcf03..48447c2d35010e197167348156e6975ee7840338 100644 --- a/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php +++ b/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php @@ -13,14 +13,14 @@ class AddQueuedCookiesToResponseTest extends TestCase { public function testHandle(): void { - $cookieJar = new CookieJar(); + $cookieJar = new CookieJar; $cookieOne = $cookieJar->make('foo', 'bar', 0, '/path'); $cookieTwo = $cookieJar->make('foo', 'rab', 0, '/'); $cookieJar->queue($cookieOne); $cookieJar->queue($cookieTwo); $addQueueCookiesToResponseMiddleware = new AddQueuedCookiesToResponse($cookieJar); $next = function (Request $request) { - return new Response(); + return new Response; }; $this->assertEquals( [ @@ -33,7 +33,7 @@ class AddQueuedCookiesToResponseTest extends TestCase ], ], ], - $addQueueCookiesToResponseMiddleware->handle(new Request(), $next)->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY) + $addQueueCookiesToResponseMiddleware->handle(new Request, $next)->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY) ); } } diff --git a/tests/Cookie/Middleware/EncryptCookiesTest.php b/tests/Cookie/Middleware/EncryptCookiesTest.php index 0c41742318dace4bd0f2726de8942ff9dda16a18..321c2edc38278a7ee28b8e2ff1249f7fd46ef404 100644 --- a/tests/Cookie/Middleware/EncryptCookiesTest.php +++ b/tests/Cookie/Middleware/EncryptCookiesTest.php @@ -19,7 +19,7 @@ use Symfony\Component\HttpFoundation\Cookie; class EncryptCookiesTest extends TestCase { /** - * @var Router + * @var \Illuminate\Routing\Router */ protected $router; diff --git a/tests/Database/DatabaseAbstractSchemaGrammarTest.php b/tests/Database/DatabaseAbstractSchemaGrammarTest.php new file mode 100755 index 0000000000000000000000000000000000000000..04e23eb264be07b15f06ac040eb966b5ec656bd3 --- /dev/null +++ b/tests/Database/DatabaseAbstractSchemaGrammarTest.php @@ -0,0 +1,37 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Connection; +use Illuminate\Database\Schema\Grammars\Grammar; +use LogicException; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class DatabaseAbstractSchemaGrammarTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testCreateDatabase() + { + $grammar = new class extends Grammar {}; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('This database driver does not support creating databases.'); + + $grammar->compileCreateDatabase('foo', m::mock(Connection::class)); + } + + public function testDropDatabaseIfExists() + { + $grammar = new class extends Grammar {}; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('This database driver does not support dropping databases.'); + + $grammar->compileDropDatabaseIfExists('foo'); + } +} diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index f30cc94673a9d6d74cc5758de802cfff0204fdd0..6303a5a1a0d1eabf1f3afc48e3850ba4c1f3d931 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -31,10 +31,10 @@ class DatabaseConnectionFactoryTest extends TestCase $this->db->addConnection([ 'driver' => 'sqlite', 'read' => [ - 'database' => ':memory:', + 'database' => ':memory:', ], 'write' => [ - 'database' => ':memory:', + 'database' => ':memory:', ], ], 'read_write'); diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 31273656e56aa5f77a049abbaf313d0925d4d01c..ac8281ed84379ea0ee24d2866c18d5e01459731a 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -22,6 +22,7 @@ use PDOException; use PDOStatement; use PHPUnit\Framework\TestCase; use ReflectionClass; +use stdClass; class DatabaseConnectionTest extends TestCase { @@ -57,11 +58,11 @@ class DatabaseConnectionTest extends TestCase public function testSelectProperlyCallsPDO() { - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['prepare'])->getMock(); - $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['prepare'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); $writePdo->expects($this->never())->method('prepare'); $statement = $this->getMockBuilder('PDOStatement') - ->setMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue']) + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue']) ->getMock(); $statement->expects($this->once())->method('setFetchMode'); $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); @@ -98,22 +99,22 @@ class DatabaseConnectionTest extends TestCase public function testDeleteCallsTheAffectingStatementMethod() { $connection = $this->getMockConnection(['affectingStatement']); - $connection->expects($this->once())->method('affectingStatement')->with($this->equalTo('foo'), $this->equalTo(['bar']))->willReturn('baz'); + $connection->expects($this->once())->method('affectingStatement')->with($this->equalTo('foo'), $this->equalTo(['bar']))->willReturn(true); $results = $connection->delete('foo', ['bar']); - $this->assertSame('baz', $results); + $this->assertTrue($results); } public function testStatementProperlyCallsPDO() { - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['prepare'])->getMock(); - $statement = $this->getMockBuilder('PDOStatement')->setMethods(['execute', 'bindValue'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'bindValue'])->getMock(); $statement->expects($this->once())->method('bindValue')->with(1, 'bar', 2); - $statement->expects($this->once())->method('execute')->willReturn('foo'); + $statement->expects($this->once())->method('execute')->willReturn(true); $pdo->expects($this->once())->method('prepare')->with($this->equalTo('foo'))->willReturn($statement); $mock = $this->getMockConnection(['prepareBindings'], $pdo); $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['bar']))->willReturn(['bar']); $results = $mock->statement('foo', ['bar']); - $this->assertSame('foo', $results); + $this->assertTrue($results); $log = $mock->getQueryLog(); $this->assertSame('foo', $log[0]['query']); $this->assertEquals(['bar'], $log[0]['bindings']); @@ -122,16 +123,16 @@ class DatabaseConnectionTest extends TestCase public function testAffectingStatementProperlyCallsPDO() { - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['prepare'])->getMock(); - $statement = $this->getMockBuilder('PDOStatement')->setMethods(['execute', 'rowCount', 'bindValue'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'rowCount', 'bindValue'])->getMock(); $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); $statement->expects($this->once())->method('execute'); - $statement->expects($this->once())->method('rowCount')->willReturn(['boom']); + $statement->expects($this->once())->method('rowCount')->willReturn(42); $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); $mock = $this->getMockConnection(['prepareBindings'], $pdo); $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); $results = $mock->update('foo', ['foo' => 'bar']); - $this->assertEquals(['boom'], $results); + $this->assertSame(42, $results); $log = $mock->getQueryLog(); $this->assertSame('foo', $log[0]['query']); $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); @@ -154,7 +155,7 @@ class DatabaseConnectionTest extends TestCase { $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); $pdo->method('beginTransaction') - ->willReturnOnConsecutiveCalls($this->throwException(new ErrorException('server has gone away'))); + ->willReturnOnConsecutiveCalls($this->throwException(new ErrorException('server has gone away')), true); $connection = $this->getMockConnection(['reconnect'], $pdo); $connection->expects($this->once())->method('reconnect'); $connection->beginTransaction(); @@ -246,7 +247,7 @@ class DatabaseConnectionTest extends TestCase public function testTransactionMethodRunsSuccessfully() { - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['beginTransaction', 'commit'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit'])->getMock(); $mock = $this->getMockConnection([], $pdo); $pdo->expects($this->once())->method('beginTransaction'); $pdo->expects($this->once())->method('commit'); @@ -261,7 +262,7 @@ class DatabaseConnectionTest extends TestCase $this->expectException(PDOException::class); $this->expectExceptionMessage('Serialization failure'); - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); $mock = $this->getMockConnection([], $pdo); $pdo->expects($this->exactly(3))->method('commit')->will($this->throwException(new DatabaseConnectionTestMockPDOException('Serialization failure', '40001'))); $pdo->expects($this->exactly(3))->method('beginTransaction'); @@ -275,7 +276,7 @@ class DatabaseConnectionTest extends TestCase $this->expectException(QueryException::class); $this->expectExceptionMessage('Deadlock found when trying to get lock (SQL: )'); - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); $mock = $this->getMockConnection([], $pdo); $pdo->expects($this->exactly(3))->method('beginTransaction'); $pdo->expects($this->exactly(3))->method('rollBack'); @@ -287,7 +288,7 @@ class DatabaseConnectionTest extends TestCase public function testTransactionMethodRollsbackAndThrows() { - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); $mock = $this->getMockConnection([], $pdo); $pdo->expects($this->once())->method('beginTransaction'); $pdo->expects($this->once())->method('rollBack'); @@ -323,7 +324,7 @@ class DatabaseConnectionTest extends TestCase $statement = m::mock(PDOStatement::class); $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); - $statement->shouldReceive('execute')->once()->andReturn('result'); + $statement->shouldReceive('execute')->once()->andReturn(true); $pdo->shouldReceive('prepare')->twice()->andReturn($statement); @@ -335,7 +336,7 @@ class DatabaseConnectionTest extends TestCase $called = true; }); - $this->assertSame('result', $connection->statement('foo')); + $this->assertTrue($connection->statement('foo')); $this->assertTrue($called); } @@ -362,7 +363,7 @@ class DatabaseConnectionTest extends TestCase $method = (new ReflectionClass(Connection::class))->getMethod('run'); $method->setAccessible(true); - $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->setMethods(['beginTransaction'])->getMock(); + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction'])->getMock(); $mock = $this->getMockConnection(['tryAgainIfCausedByLostConnection'], $pdo); $pdo->expects($this->once())->method('beginTransaction'); $mock->expects($this->never())->method('tryAgainIfCausedByLostConnection'); @@ -405,6 +406,18 @@ class DatabaseConnectionTest extends TestCase $connection->logQuery('foo', [], null); } + public function testBeforeExecutingHooksCanBeRegistered() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The callback was fired'); + + $connection = $this->getMockConnection(); + $connection->beforeExecuting(function () { + throw new Exception('The callback was fired'); + }); + $connection->select('foo bar', ['baz']); + } + public function testPretendOnlyLogsQueries() { $connection = $this->getMockConnection(); @@ -427,7 +440,7 @@ class DatabaseConnectionTest extends TestCase { $pdo = $pdo ?: new DatabaseConnectionTestMockPDO; $defaults = ['getDefaultQueryGrammar', 'getDefaultPostProcessor', 'getDefaultSchemaGrammar']; - $connection = $this->getMockBuilder(Connection::class)->setMethods(array_merge($defaults, $methods))->setConstructorArgs([$pdo])->getMock(); + $connection = $this->getMockBuilder(Connection::class)->onlyMethods(array_merge($defaults, $methods))->setConstructorArgs([$pdo])->getMock(); $connection->enableQueryLog(); return $connection; diff --git a/tests/Database/DatabaseConnectorTest.php b/tests/Database/DatabaseConnectorTest.php index f1e4a03c6462547e5e78c314fb19ee9e08470688..1bbd6a34d6e121cdb26889ade210ab6a1fe6b433 100755 --- a/tests/Database/DatabaseConnectorTest.php +++ b/tests/Database/DatabaseConnectorTest.php @@ -9,7 +9,9 @@ use Illuminate\Database\Connectors\SQLiteConnector; use Illuminate\Database\Connectors\SqlServerConnector; use Mockery as m; use PDO; +use PDOStatement; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseConnectorTest extends TestCase { @@ -30,12 +32,13 @@ class DatabaseConnectorTest extends TestCase */ public function testMySqlConnectCallsCreateConnectionWithProperArguments($dsn, $config) { - $connector = $this->getMockBuilder(MySqlConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(MySqlConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(PDO::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set names \'utf8\' collate \'utf8_unicode_ci\'')->andReturn($connection); - $connection->shouldReceive('execute')->once(); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\' collate \'utf8_unicode_ci\'')->andReturn($statement); + $statement->shouldReceive('execute')->once(); $connection->shouldReceive('exec')->zeroOrMoreTimes(); $result = $connector->connect($config); @@ -51,16 +54,36 @@ class DatabaseConnectorTest extends TestCase ]; } + public function testMySqlConnectCallsCreateConnectionWithIsolationLevel() + { + $dsn = 'mysql:host=foo;dbname=bar'; + $config = ['host' => 'foo', 'database' => 'bar', 'collation' => 'utf8_unicode_ci', 'charset' => 'utf8', 'isolation_level' => 'REPEATABLE READ']; + + $connector = $this->getMockBuilder(MySqlConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\' collate \'utf8_unicode_ci\'')->andReturn($statement); + $connection->shouldReceive('prepare')->once()->with('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ')->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $connection->shouldReceive('exec')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + public function testPostgresConnectCallsCreateConnectionWithProperArguments() { - $dsn = 'pgsql:host=foo;dbname=bar;port=111'; + $dsn = 'pgsql:host=foo;dbname=\'bar\';port=111'; $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'charset' => 'utf8']; - $connector = $this->getMockBuilder(PostgresConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($connection); - $connection->shouldReceive('execute')->once(); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($statement); + $statement->shouldReceive('execute')->once(); $result = $connector->connect($config); $this->assertSame($result, $connection); @@ -68,15 +91,16 @@ class DatabaseConnectorTest extends TestCase public function testPostgresSearchPathIsSet() { - $dsn = 'pgsql:host=foo;dbname=bar'; + $dsn = 'pgsql:host=foo;dbname=\'bar\''; $config = ['host' => 'foo', 'database' => 'bar', 'schema' => 'public', 'charset' => 'utf8']; - $connector = $this->getMockBuilder(PostgresConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set search_path to "public"')->andReturn($connection); - $connection->shouldReceive('execute')->twice(); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($statement); + $connection->shouldReceive('prepare')->once()->with('set search_path to "public"')->andReturn($statement); + $statement->shouldReceive('execute')->twice(); $result = $connector->connect($config); $this->assertSame($result, $connection); @@ -84,15 +108,16 @@ class DatabaseConnectorTest extends TestCase public function testPostgresSearchPathArraySupported() { - $dsn = 'pgsql:host=foo;dbname=bar'; + $dsn = 'pgsql:host=foo;dbname=\'bar\''; $config = ['host' => 'foo', 'database' => 'bar', 'schema' => ['public', 'user'], 'charset' => 'utf8']; - $connector = $this->getMockBuilder(PostgresConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set search_path to "public", "user"')->andReturn($connection); - $connection->shouldReceive('execute')->twice(); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($statement); + $connection->shouldReceive('prepare')->once()->with('set search_path to "public", "user"')->andReturn($statement); + $statement->shouldReceive('execute')->twice(); $result = $connector->connect($config); $this->assertSame($result, $connection); @@ -100,15 +125,33 @@ class DatabaseConnectorTest extends TestCase public function testPostgresApplicationNameIsSet() { - $dsn = 'pgsql:host=foo;dbname=bar'; + $dsn = 'pgsql:host=foo;dbname=\'bar\''; $config = ['host' => 'foo', 'database' => 'bar', 'charset' => 'utf8', 'application_name' => 'Laravel App']; - $connector = $this->getMockBuilder(PostgresConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($connection); - $connection->shouldReceive('prepare')->once()->with('set application_name to \'Laravel App\'')->andReturn($connection); - $connection->shouldReceive('execute')->twice(); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set names \'utf8\'')->andReturn($statement); + $connection->shouldReceive('prepare')->once()->with('set application_name to \'Laravel App\'')->andReturn($statement); + $statement->shouldReceive('execute')->twice(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresConnectorReadsIsolationLevelFromConfig() + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';port=111'; + $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'isolation_level' => 'SERIALIZABLE']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set session characteristics as transaction isolation level SERIALIZABLE')->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $connection->shouldReceive('exec')->zeroOrMoreTimes(); $result = $connector->connect($config); $this->assertSame($result, $connection); @@ -118,7 +161,7 @@ class DatabaseConnectorTest extends TestCase { $dsn = 'sqlite::memory:'; $config = ['database' => ':memory:']; - $connector = $this->getMockBuilder(SQLiteConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); @@ -131,7 +174,7 @@ class DatabaseConnectorTest extends TestCase { $dsn = 'sqlite:'.__DIR__; $config = ['database' => __DIR__]; - $connector = $this->getMockBuilder(SQLiteConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); @@ -144,7 +187,7 @@ class DatabaseConnectorTest extends TestCase { $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111]; $dsn = $this->getDsn($config); - $connector = $this->getMockBuilder(SqlServerConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(SqlServerConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); @@ -157,7 +200,7 @@ class DatabaseConnectorTest extends TestCase { $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'readonly' => true, 'charset' => 'utf-8', 'pooling' => false, 'appname' => 'baz']; $dsn = $this->getDsn($config); - $connector = $this->getMockBuilder(SqlServerConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(SqlServerConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); @@ -166,15 +209,14 @@ class DatabaseConnectorTest extends TestCase $this->assertSame($result, $connection); } + /** + * @requires extension odbc + */ public function testSqlServerConnectCallsCreateConnectionWithPreferredODBC() { - if (! in_array('odbc', PDO::getAvailableDrivers())) { - $this->markTestSkipped('PHP was compiled without PDO ODBC support.'); - } - $config = ['odbc' => true, 'odbc_datasource_name' => 'server=localhost;database=test;']; $dsn = $this->getDsn($config); - $connector = $this->getMockBuilder(SqlServerConnector::class)->setMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(SqlServerConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(stdClass::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); diff --git a/tests/Database/DatabaseEloquentBelongsToManyChunkByIdTest.php b/tests/Database/DatabaseEloquentBelongsToManyChunkByIdTest.php index ee1688df79c7d9f35c5d46ffd3129f60b231ddfc..f35c2f9a3ce988bd6420b02e783cc6c24b709a01 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyChunkByIdTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyChunkByIdTest.php @@ -58,10 +58,10 @@ class DatabaseEloquentBelongsToManyChunkByIdTest extends TestCase $user->articles()->chunkById(1, function (Collection $collection) use (&$i) { $i++; - $this->assertTrue($collection->first()->aid == $i); + $this->assertEquals($i, $collection->first()->aid); }); - $this->assertTrue($i === 3); + $this->assertSame(3, $i); } /** diff --git a/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cbdf1ffbda19082f95739e5054061cae5df0d9b9 --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php @@ -0,0 +1,134 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model as Eloquent; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentBelongsToManyLazyByIdTest extends TestCase +{ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('aid'); + $table->string('title'); + }); + + $this->schema()->create('article_user', function ($table) { + $table->integer('article_id')->unsigned(); + $table->foreign('article_id')->references('aid')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + public function testBelongsToLazyById() + { + $this->seedData(); + + $user = BelongsToManyLazyByIdTestTestUser::query()->first(); + $i = 0; + + $user->articles()->lazyById(1)->each(function ($model) use (&$i) { + $i++; + $this->assertEquals($i, $model->aid); + }); + + $this->assertSame(3, $i); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = BelongsToManyLazyByIdTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + BelongsToManyLazyByIdTestTestArticle::query()->insert([ + ['aid' => 1, 'title' => 'Another title'], + ['aid' => 2, 'title' => 'Another title'], + ['aid' => 3, 'title' => 'Another title'], + ]); + + $user->articles()->sync([3, 1, 2]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManyLazyByIdTestTestUser extends Eloquent +{ + protected $table = 'users'; + protected $fillable = ['id', 'email']; + public $timestamps = false; + + public function articles() + { + return $this->belongsToMany(BelongsToManyLazyByIdTestTestArticle::class, 'article_user', 'user_id', 'article_id'); + } +} + +class BelongsToManyLazyByIdTestTestArticle extends Eloquent +{ + protected $primaryKey = 'aid'; + protected $table = 'articles'; + protected $keyType = 'string'; + public $incrementing = false; + public $timestamps = false; + protected $fillable = ['aid', 'title']; +} diff --git a/tests/Database/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php b/tests/Database/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php index 5b37018c7cd80523a4684abfa140c0056141965e..49d82a96e3b2188d48340df3df7e0eb52765d132 100644 --- a/tests/Database/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php @@ -13,8 +13,8 @@ class DatabaseEloquentBelongsToManySyncReturnValueTypeTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -47,6 +47,7 @@ class DatabaseEloquentBelongsToManySyncReturnValueTypeTest extends TestCase $table->foreign('article_id')->references('id')->on('articles'); $table->integer('user_id')->unsigned(); $table->foreign('user_id')->references('id')->on('users'); + $table->boolean('visible')->default(false); }); } @@ -85,7 +86,29 @@ class DatabaseEloquentBelongsToManySyncReturnValueTypeTest extends TestCase $changes = $user->articles()->sync($articleIDs); collect($changes['attached'])->map(function ($id) { - $this->assertTrue(gettype($id) === (new BelongsToManySyncTestTestArticle)->getKeyType()); + $this->assertSame(gettype($id), (new BelongsToManySyncTestTestArticle)->getKeyType()); + }); + + $user->articles->each(function (BelongsToManySyncTestTestArticle $article) { + $this->assertEquals('0', $article->pivot->visible); + }); + } + + public function testSyncWithPivotDefaultsReturnValueType() + { + $this->seedData(); + + $user = BelongsToManySyncTestTestUser::query()->first(); + $articleIDs = BelongsToManySyncTestTestArticle::all()->pluck('id')->toArray(); + + $changes = $user->articles()->syncWithPivotValues($articleIDs, ['visible' => true]); + + collect($changes['attached'])->each(function ($id) { + $this->assertSame(gettype($id), (new BelongsToManySyncTestTestArticle)->getKeyType()); + }); + + $user->articles->each(function (BelongsToManySyncTestTestArticle $article) { + $this->assertEquals('1', $article->pivot->visible); }); } @@ -118,7 +141,7 @@ class BelongsToManySyncTestTestUser extends Eloquent public function articles() { - return $this->belongsToMany(BelongsToManySyncTestTestArticle::class, 'article_user', 'user_id', 'article_id'); + return $this->belongsToMany(BelongsToManySyncTestTestArticle::class, 'article_user', 'user_id', 'article_id')->withPivot('visible'); } } diff --git a/tests/Database/DatabaseEloquentBelongsToManySyncTouchesParentTest.php b/tests/Database/DatabaseEloquentBelongsToManySyncTouchesParentTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4afde0a6bc01be2cbd7d9277b4066662d6973292 --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManySyncTouchesParentTest.php @@ -0,0 +1,173 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Relations\Pivot as EloquentPivot; +use Illuminate\Support\Carbon; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentBelongsToManySyncTouchesParentTest extends TestCase +{ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('articles', function ($table) { + $table->string('id'); + $table->string('title'); + + $table->primary('id'); + $table->timestamps(); + }); + + $this->schema()->create('article_user', function ($table) { + $table->string('article_id'); + $table->foreign('article_id')->references('id')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + $table->timestamps(); + }); + + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + } + + /** + * Helpers... + */ + protected function seedData() + { + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 2, 'email' => 'anonymous@gmail.com']); + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 3, 'email' => 'anoni-mous@gmail.com']); + } + + public function testSyncWithDetachedValuesShouldTouch() + { + $this->seedData(); + + Carbon::setTestNow('2021-07-19 10:13:14'); + $article = DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::create(['id' => 1, 'title' => 'uuid title']); + $article->users()->sync([1, 2, 3]); + $this->assertSame('2021-07-19 10:13:14', $article->updated_at->format('Y-m-d H:i:s')); + + Carbon::setTestNow('2021-07-20 19:13:14'); + $result = $article->users()->sync([1, 2]); + $this->assertTrue(collect($result['detached'])->count() === 1); + $this->assertSame('3', collect($result['detached'])->first()); + + $article->refresh(); + $this->assertSame('2021-07-20 19:13:14', $article->updated_at->format('Y-m-d H:i:s')); + + $user1 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(1); + $this->assertNotSame('2021-07-20 19:13:14', $user1->updated_at->format('Y-m-d H:i:s')); + $user2 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(2); + $this->assertNotSame('2021-07-20 19:13:14', $user2->updated_at->format('Y-m-d H:i:s')); + $user3 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(3); + $this->assertNotSame('2021-07-20 19:13:14', $user3->updated_at->format('Y-m-d H:i:s')); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle extends Eloquent +{ + protected $table = 'articles'; + protected $keyType = 'string'; + public $incrementing = false; + protected $fillable = ['id', 'title']; + + public function users() + { + return $this + ->belongsToMany(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_user', 'article_id', 'user_id') + ->using(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser::class) + ->withTimestamps(); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser extends EloquentPivot +{ + protected $table = 'article_user'; + protected $fillable = ['article_id', 'user_id']; + protected $touches = ['article']; + + public function article() + { + return $this->belongsTo(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_id', 'id'); + } + + public function user() + { + return $this->belongsTo(DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::class, 'user_id', 'id'); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser extends Eloquent +{ + protected $table = 'users'; + protected $keyType = 'string'; + public $incrementing = false; + protected $fillable = ['id', 'email']; + + public function articles() + { + return $this + ->belongsToMany(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_user', 'user_id', 'article_id') + ->using(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser::class) + ->withTimestamps(); + } +} diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bd870f59bbccf2b465f3ad28245cc4cdfacdf94b --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -0,0 +1,80 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentBelongsToManyWithCastedAttributesTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + $model1 = m::mock(Model::class); + $model1->shouldReceive('getAttribute')->with('parent_key')->andReturn(1); + $model1->shouldReceive('getAttribute')->with('foo')->passthru(); + $model1->shouldReceive('hasGetMutator')->andReturn(false); + $model1->shouldReceive('hasAttributeMutator')->andReturn(false); + $model1->shouldReceive('getCasts')->andReturn([]); + $model1->shouldReceive('getRelationValue', 'relationLoaded', 'setRelation', 'isRelation')->passthru(); + + $model2 = m::mock(Model::class); + $model2->shouldReceive('getAttribute')->with('parent_key')->andReturn(2); + $model2->shouldReceive('getAttribute')->with('foo')->passthru(); + $model2->shouldReceive('hasGetMutator')->andReturn(false); + $model2->shouldReceive('hasAttributeMutator')->andReturn(false); + $model2->shouldReceive('getCasts')->andReturn([]); + $model2->shouldReceive('getRelationValue', 'relationLoaded', 'setRelation', 'isRelation')->passthru(); + + $result1 = (object) [ + 'pivot' => (object) [ + 'foreign_key' => new class + { + public function __toString() + { + return '1'; + } + }, + ], + ]; + + $models = $relation->match([$model1, $model2], Collection::wrap($result1), 'foo'); + self::assertNull($models[1]->foo); + self::assertEquals(1, $models[0]->foo->count()); + self::assertContains($result1, $models[0]->foo); + } + + protected function getRelation() + { + $builder = m::mock(Builder::class); + $related = m::mock(Model::class); + $related->shouldReceive('newCollection')->passthru(); + $builder->shouldReceive('getModel')->andReturn($related); + $related->shouldReceive('qualifyColumn'); + $builder->shouldReceive('join', 'where'); + + return new BelongsToMany( + $builder, + new EloquentBelongsToManyModelStub, + 'relation', + 'foreign_key', + 'id', + 'parent_key', + 'related_key' + ); + } +} + +class EloquentBelongsToManyModelStub extends Model +{ + public $foreign_key = 'foreign.value'; +} diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php index 584eaabd5c7354e4dc1ca89f9924c64424c3697a..512f8745469173ff5473de4d8df25eeb2b95ffc4 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseEloquentBelongsToManyWithDefaultAttributesTest extends TestCase { @@ -17,13 +18,13 @@ class DatabaseEloquentBelongsToManyWithDefaultAttributesTest extends TestCase public function testWithPivotValueMethodSetsWhereConditionsForFetching() { - $relation = $this->getMockBuilder(BelongsToMany::class)->setMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation = $this->getMockBuilder(BelongsToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); $relation->withPivotValue(['is_admin' => 1]); } public function testWithPivotValueMethodSetsDefaultArgumentsForInsertion() { - $relation = $this->getMockBuilder(BelongsToMany::class)->setMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation = $this->getMockBuilder(BelongsToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); $relation->withPivotValue(['is_admin' => 1]); $query = m::mock(stdClass::class); @@ -49,6 +50,7 @@ class DatabaseEloquentBelongsToManyWithDefaultAttributesTest extends TestCase $related->shouldReceive('getTable')->andReturn('users'); $related->shouldReceive('getKeyName')->andReturn('id'); + $related->shouldReceive('qualifyColumn')->with('id')->andReturn('users.id'); $builder->shouldReceive('join')->once()->with('club_user', 'users.id', '=', 'club_user.user_id'); $builder->shouldReceive('where')->once()->with('club_user.club_id', '=', 1); diff --git a/tests/Database/DatabaseEloquentBelongsToTest.php b/tests/Database/DatabaseEloquentBelongsToTest.php index 06b2087ff395f084106856052b4347d80b1e619a..0c891aaf865cecf35b91809351ff74c485823c8f 100755 --- a/tests/Database/DatabaseEloquentBelongsToTest.php +++ b/tests/Database/DatabaseEloquentBelongsToTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseEloquentBelongsToTest extends TestCase { @@ -22,11 +23,11 @@ class DatabaseEloquentBelongsToTest extends TestCase public function testBelongsToWithDefault() { - $relation = $this->getRelation()->withDefault(); //belongsTo relationships + $relation = $this->getRelation()->withDefault(); $this->builder->shouldReceive('first')->once()->andReturnNull(); - $newModel = new EloquentBelongsToModelStub; //ie Blog + $newModel = new EloquentBelongsToModelStub; $this->related->shouldReceive('newInstance')->once()->andReturn($newModel); @@ -108,14 +109,31 @@ class DatabaseEloquentBelongsToTest extends TestCase $result1->shouldReceive('getAttribute')->with('id')->andReturn(1); $result2 = m::mock(stdClass::class); $result2->shouldReceive('getAttribute')->with('id')->andReturn(2); + $result3 = m::mock(stdClass::class); + $result3->shouldReceive('getAttribute')->with('id')->andReturn(new class + { + public function __toString() + { + return '3'; + } + }); $model1 = new EloquentBelongsToModelStub; $model1->foreign_key = 1; $model2 = new EloquentBelongsToModelStub; $model2->foreign_key = 2; - $models = $relation->match([$model1, $model2], new Collection([$result1, $result2]), 'foo'); + $model3 = new EloquentBelongsToModelStub; + $model3->foreign_key = new class + { + public function __toString() + { + return '3'; + } + }; + $models = $relation->match([$model1, $model2, $model3], new Collection([$result1, $result2, $result3]), 'foo'); $this->assertEquals(1, $models[0]->foo->getAttribute('id')); $this->assertEquals(2, $models[1]->foo->getAttribute('id')); + $this->assertEquals('3', $models[2]->foo->getAttribute('id')); } public function testAssociateMethodSetsForeignKeyOnModel() @@ -137,7 +155,10 @@ class DatabaseEloquentBelongsToTest extends TestCase $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); $relation = $this->getRelation($parent); $parent->shouldReceive('setAttribute')->once()->with('foreign_key', null); + + // Always set relation when we received Model $parent->shouldReceive('setRelation')->once()->with('relation', null); + $relation->dissociate(); } @@ -147,8 +168,11 @@ class DatabaseEloquentBelongsToTest extends TestCase $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); $relation = $this->getRelation($parent); $parent->shouldReceive('setAttribute')->once()->with('foreign_key', 1); - $parent->shouldReceive('isDirty')->once()->andReturn(true); + + // Always unset relation when we received id, regardless of dirtiness + $parent->shouldReceive('isDirty')->never(); $parent->shouldReceive('unsetRelation')->once()->with($relation->getRelationName()); + $relation->associate(1); } @@ -180,6 +204,169 @@ class DatabaseEloquentBelongsToTest extends TestCase $relation->addEagerConstraints($models); } + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerRelatedKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return a string + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerKeys() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return null + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value.two'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation.two'); + + $this->assertFalse($relation->is($model)); + } + protected function getRelation($parent = null, $keyType = 'int') { $this->builder = m::mock(Builder::class); diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 2c40e2106a0e29688c809371074b37dbf55ae8ec..63f9b08de405cff5f4599ec7b5c1c53f847c442f 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -3,7 +3,6 @@ namespace Illuminate\Tests\Database; use BadMethodCallException; -use Carbon\Carbon; use Closure; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionResolverInterface; @@ -12,10 +11,12 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Query\Builder as BaseBuilder; use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Query\Processors\Processor; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection as BaseCollection; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -132,6 +133,17 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->findOrFail([1, 2], ['column']); } + public function testFindOrFailMethodWithManyUsingCollectionThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $builder->setModel($this->getMockModel()); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('foo_table.foo', [1, 2]); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([1])); + $builder->findOrFail(new Collection([1, 2]), ['column']); + } + public function testFirstOrFailMethodThrowsModelNotFoundException() { $this->expectException(ModelNotFoundException::class); @@ -185,6 +197,16 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertSame('stub.column', $builder->qualifyColumn('column')); } + public function testQualifyColumns() + { + $builder = new Builder(m::mock(BaseBuilder::class)); + $builder->shouldReceive('from')->with('stub'); + + $builder->setModel(new EloquentModelStub); + + $this->assertEquals(['stub.column', 'stub.name'], $builder->qualifyColumns(['column', 'name'])); + } + public function testGetMethodLoadsModelsAndHydratesEagerRelations() { $builder = m::mock(Builder::class.'[getModels,eagerLoadRelations]', [$this->getMockQueryBuilder()]); @@ -229,6 +251,29 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertNull($builder->value('name')); } + public function testValueOrFailMethodWithModelFound() + { + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $mockModel = new stdClass; + $mockModel->name = 'foo'; + $builder->shouldReceive('first')->with(['name'])->andReturn($mockModel); + + $this->assertSame('foo', $builder->valueOrFail('name')); + } + + public function testValueOrFailMethodWithModelNotFoundThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn(null); + $builder->whereKey('bar')->valueOrFail('column'); + } + public function testChunkWithLastChunkComplete() { $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); @@ -371,6 +416,118 @@ class DatabaseEloquentBuilderTest extends TestCase }, 'someIdField'); } + public function testLazyWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(2, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(3, 2)->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn( + new Collection(['foo1', 'foo2']), + new Collection(['foo3', 'foo4']), + new Collection([]) + ); + + $this->assertEquals( + ['foo1', 'foo2', 'foo3', 'foo4'], + $builder->lazy(2)->all() + ); + } + + public function testLazyWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(2, 2)->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn( + new Collection(['foo1', 'foo2']), + new Collection(['foo3']) + ); + + $this->assertEquals( + ['foo1', 'foo2', 'foo3'], + $builder->lazy(2)->all() + ); + } + + public function testLazyIsLazy() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn(new Collection(['foo1', 'foo2'])); + + $this->assertEquals(['foo1', 'foo2'], $builder->lazy(2)->take(2)->all()); + } + + public function testLazyByIdWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10], (object) ['someIdField' => 11]]); + $chunk3 = new Collection([]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + (object) ['someIdField' => 10], + (object) ['someIdField' => 11], + ], + $builder->lazyById(2, 'someIdField')->all() + ); + } + + public function testLazyByIdWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10]]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + (object) ['someIdField' => 10], + ], + $builder->lazyById(2, 'someIdField')->all() + ); + } + + public function testLazyByIdIsLazy() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($chunk1); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + ], + $builder->lazyById(2, 'someIdField')->take(2)->all() + ); + } + public function testPluckReturnsTheMutatedAttributesOfAModel() { $builder = $this->getBuilder(); @@ -456,7 +613,7 @@ class DatabaseEloquentBuilderTest extends TestCase $builder = $this->getBuilder(); $this->assertTrue(Builder::hasGlobalMacro('foo')); - $this->assertEquals($builder->foo('bar'), 'bar'); + $this->assertSame('bar', $builder->foo('bar')); $this->assertEquals($builder->bam(), $builder->getQuery()); } @@ -480,7 +637,7 @@ class DatabaseEloquentBuilderTest extends TestCase $model->shouldReceive('hydrate')->once()->with($records)->andReturn(new Collection(['hydrated'])); $models = $builder->getModels(['foo']); - $this->assertEquals($models, ['hydrated']); + $this->assertEquals(['hydrated'], $models); } public function testEagerLoadRelationsLoadTopLevelRelationships() @@ -604,6 +761,16 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertInstanceOf(Closure::class, $eagers['orders']); $this->assertNull($eagers['orders']()); $this->assertSame('foo', $eagers['orders.lines']()); + + $builder = $this->getBuilder(); + $builder->with('orders.lines', function () { + return 'foo'; + }); + $eagers = $builder->getEagerLoads(); + + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertNull($eagers['orders']()); + $this->assertSame('foo', $eagers['orders.lines']()); } public function testQueryPassThru() @@ -632,6 +799,16 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->getQuery()->shouldReceive('insertUsing')->once()->with(['bar'], 'baz')->andReturn('foo'); $this->assertSame('foo', $builder->insertUsing(['bar'], 'baz')); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('raw')->once()->with('bar')->andReturn('foo'); + + $this->assertSame('foo', $builder->raw('bar')); + + $builder = $this->getBuilder(); + $grammar = new Grammar; + $builder->getQuery()->shouldReceive('getGrammar')->once()->andReturn($grammar); + $this->assertSame($grammar, $builder->getGrammar()); } public function testQueryScopes() @@ -729,6 +906,35 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertEquals($result, $builder); } + public function testWhereBelongsTo() + { + $related = new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 1, + 'parent_id' => 2, + ]); + + $parent = new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 2, + 'parent_id' => 1, + ]); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('where')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', '=', 2, 'and'); + + $result = $builder->whereBelongsTo($parent); + $this->assertEquals($result, $builder); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('where')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', '=', 2, 'and'); + + $result = $builder->whereBelongsTo($parent, 'parent'); + $this->assertEquals($result, $builder); + } + public function testDeleteOverride() { $builder = $this->getBuilder(); @@ -785,6 +991,39 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertSame('select "id", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); } + public function testWithMin() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMin('foo', 'price'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select min("eloquent_builder_test_model_close_related_stubs"."price") from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_min_price" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnBelongsToMany() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMin('roles', 'id'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select min("eloquent_builder_test_model_far_related_stubs"."id") from "eloquent_builder_test_model_far_related_stubs" inner join "user_role" on "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" where "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id") as "roles_min_id" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnSelfRelated() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $sql = $model->withMin('childFoos', 'created_at')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $sql = preg_replace($aliasRegex, $alias, $sql); + + $this->assertSame('select "self_related_stubs".*, (select min("self_alias_hash"."created_at") from "self_related_stubs" as "self_alias_hash" where "self_related_stubs"."id" = "self_alias_hash"."parent_id") as "child_foos_min_created_at" from "self_related_stubs"', $sql); + } + public function testWithCountAndConstraintsAndHaving() { $model = new EloquentBuilderTestModelParentStub; @@ -816,6 +1055,95 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); } + public function testWithExists() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('foo'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndSelect() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withExists('foo'); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndMergedWheres() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withExists(['activeFoo' => function ($q) { + $q->where('bam', '>', 'qux'); + }]); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "bam" > ? and "active" = ?) as "active_foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertEquals(['qux', true], $builder->getBindings()); + } + + public function testWithExistsAndGlobalScope() + { + $model = new EloquentBuilderTestModelParentStub; + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withExists', function ($query) { + return $query->addSelect('id'); + }); + + $builder = $model->select('id')->withExists(['foo']); + + // Remove the global scope so it doesn't interfere with any other tests + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withExists', function ($query) { + // + }); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnBelongsToMany() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('roles'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_far_related_stubs" inner join "user_role" on "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" where "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id") as "roles_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnSelfRelated() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $sql = $model->withExists('childFoos')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $sql = preg_replace($aliasRegex, $alias, $sql); + + $this->assertSame('select "self_related_stubs".*, exists(select * from "self_related_stubs" as "self_alias_hash" where "self_related_stubs"."id" = "self_alias_hash"."parent_id") as "child_foos_exists" from "self_related_stubs"', $sql); + } + + public function testWithExistsAndRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('foo as foo_bar'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsMultipleAndPartialRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists(['foo as foo_bar', 'foo']); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + public function testHasWithConstraintsAndHavingInSubquery() { $model = new EloquentBuilderTestModelParentStub; @@ -885,6 +1213,19 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertSame([], $builder->getBindings()); } + public function testWithExistsAndConstraintsWithBindingInSelectSub() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->newQuery(); + $builder->withExists(['foo' => function ($q) use ($model) { + $q->selectSub($model->newQuery()->where('bam', '=', 3)->selectRaw('count(0)'), 'bam_3_count'); + }]); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + public function testHasNestedWithConstraints() { $model = new EloquentBuilderTestModelParentStub; @@ -1017,6 +1358,62 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertEquals(['baz', 'quux'], $builder->getBindings()); } + public function testWhereMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where ("morph_type" = ? and "morph_id" = ?)', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or ("morph_type" = ? and "morph_id" = ?)', $builder->toSql()); + $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereMorphedToClass() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "morph_type" = ?', $builder->toSql()); + $this->assertEquals([EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereMorphedToAlias() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + Relation::morphMap([ + 'alias' => EloquentBuilderTestModelCloseRelatedStub::class, + ]); + + $builder = $model->whereMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "morph_type" = ?', $builder->toSql()); + $this->assertEquals(['alias'], $builder->getBindings()); + + Relation::morphMap([], false); + } + public function testWhereKeyMethodWithInt() { $model = $this->getMockModel(); @@ -1033,7 +1430,7 @@ class DatabaseEloquentBuilderTest extends TestCase public function testWhereKeyMethodWithStringZero() { - $model = new EloquentBuilderTestStubStringPrimaryKey(); + $model = new EloquentBuilderTestStubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); @@ -1044,10 +1441,9 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->whereKey($int); } - /** @group Foo */ public function testWhereKeyMethodWithStringNull() { - $model = new EloquentBuilderTestStubStringPrimaryKey(); + $model = new EloquentBuilderTestStubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); @@ -1084,9 +1480,25 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->whereKey($collection); } + public function testWhereKeyMethodWithModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '=', m::on(function ($argument) { + return $argument === '1'; + })); + + $builder->whereKey(new class extends Model + { + protected $attributes = ['id' => 1]; + }); + } + public function testWhereKeyNotMethodWithStringZero() { - $model = new EloquentBuilderTestStubStringPrimaryKey(); + $model = new EloquentBuilderTestStubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); @@ -1097,10 +1509,9 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->whereKeyNot($int); } - /** @group Foo */ public function testWhereKeyNotMethodWithStringNull() { - $model = new EloquentBuilderTestStubStringPrimaryKey(); + $model = new EloquentBuilderTestStubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); @@ -1151,6 +1562,22 @@ class DatabaseEloquentBuilderTest extends TestCase $builder->whereKeyNot($collection); } + public function testWhereKeyNotMethodWithModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', m::on(function ($argument) { + return $argument === '1'; + })); + + $builder->whereKeyNot(new class extends Model + { + protected $attributes = ['id' => 1]; + }); + } + public function testWhereIn() { $model = new EloquentBuilderTestNestedStub; @@ -1256,6 +1683,20 @@ class DatabaseEloquentBuilderTest extends TestCase $this->assertEquals(1, $result); } + public function testUpdateWithQualifiedTimestampValue() + { + $query = new BaseBuilder(m::mock(ConnectionInterface::class), new Grammar, m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" set "table"."foo" = ?, "table"."updated_at" = ?', ['bar', null])->andReturn(1); + + $result = $builder->update(['table.foo' => 'bar', 'table.updated_at' => null]); + $this->assertEquals(1, $result); + } + public function testUpdateWithoutTimestamp() { $query = new BaseBuilder(m::mock(ConnectionInterface::class), new Grammar, m::mock(Processor::class)); @@ -1288,6 +1729,71 @@ class DatabaseEloquentBuilderTest extends TestCase Carbon::setTestNow(null); } + public function testUpdateWithAliasWithQualifiedTimestampValue() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $query = new BaseBuilder(m::mock(ConnectionInterface::class), new Grammar, m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" as "alias" set "foo" = ?, "alias"."updated_at" = ?', ['bar', null])->andReturn(1); + + $result = $builder->from('table as alias')->update(['foo' => 'bar', 'alias.updated_at' => null]); + $this->assertEquals(1, $result); + + Carbon::setTestNow(null); + } + + public function testUpsert() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table')->andReturn('foo_table'); + $query->from = 'foo_table'; + + $builder = new Builder($query); + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder->setModel($model); + + $query->shouldReceive('upsert')->once() + ->with([ + ['email' => 'foo', 'name' => 'bar', 'updated_at' => $now, 'created_at' => $now], + ['name' => 'bar2', 'email' => 'foo2', 'updated_at' => $now, 'created_at' => $now], + ], ['email'], ['email', 'name', 'updated_at'])->andReturn(2); + + $result = $builder->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], ['email']); + + $this->assertEquals(2, $result); + + Carbon::setTestNow(null); + } + + public function testWithCastsMethod() + { + $builder = new Builder($this->getMockQueryBuilder()); + $model = $this->getMockModel(); + $builder->setModel($model); + + $model->shouldReceive('mergeCasts')->with(['foo' => 'bar'])->once(); + $builder->withCasts(['foo' => 'bar']); + } + + public function testClone() + { + $query = new BaseBuilder(m::mock(ConnectionInterface::class), new Grammar, m::mock(Processor::class)); + $builder = new Builder($query); + $builder->select('*')->from('users'); + $clone = $builder->clone()->where('email', 'foo'); + + $this->assertNotSame($builder, $clone); + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + protected function mockConnectionForModel($model, $database) { $grammarClass = 'Illuminate\Database\Query\Grammars\\'.$database.'Grammar'; @@ -1298,6 +1804,7 @@ class DatabaseEloquentBuilderTest extends TestCase $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { return new BaseBuilder($connection, $grammar, $processor); }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); $class = get_class($model); $class::setConnectionResolver($resolver); @@ -1417,6 +1924,21 @@ class EloquentBuilderTestModelParentStub extends Model { return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class, 'foo_id')->where('active', true); } + + public function roles() + { + return $this->belongsToMany( + EloquentBuilderTestModelFarRelatedStub::class, + 'user_role', + 'self_id', + 'related_id' + ); + } + + public function morph() + { + return $this->morphTo(); + } } class EloquentBuilderTestModelCloseRelatedStub extends Model @@ -1487,3 +2009,21 @@ class EloquentBuilderTestStubStringPrimaryKey extends Model protected $keyType = 'string'; } + +class EloquentBuilderTestWhereBelongsToStub extends Model +{ + protected $fillable = [ + 'id', + 'parent_id', + ]; + + public function eloquentBuilderTestWhereBelongsToStub() + { + return $this->belongsTo(self::class, 'parent_id', 'id', 'parent'); + } + + public function parent() + { + return $this->belongsTo(self::class, 'parent_id', 'id', 'parent'); + } +} diff --git a/tests/Database/DatabaseEloquentCollectionQueueableTest.php b/tests/Database/DatabaseEloquentCollectionQueueableTest.php index 556bff393681241b451c1934d25498e0a0f0d324..dfd3417809f6f66e71c64cc3a526822e962fada4 100644 --- a/tests/Database/DatabaseEloquentCollectionQueueableTest.php +++ b/tests/Database/DatabaseEloquentCollectionQueueableTest.php @@ -60,8 +60,8 @@ class DatabaseEloquentCollectionQueueableTest extends TestCase 'ids' => $c->getQueueableIds(), ]; - $this->assertTrue( - json_encode($payload) !== false, + $this->assertNotFalse( + json_encode($payload), 'EloquentCollection is not using the QueueableEntity::getQueueableId() method.' ); } diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index 44669d314688fa342bfbe3cd72475e3f8ab623a9..b242dcb374475c7b77c528fb37ff3a1b59230d1e 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -2,17 +2,64 @@ namespace Illuminate\Tests\Database; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Support\Collection as BaseCollection; use LogicException; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseEloquentCollectionTest extends TestCase { + /** + * Setup the database schema. + * + * @return void + */ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('title'); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->integer('article_id'); + $table->string('content'); + }); + } + protected function tearDown(): void { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('comments'); m::close(); } @@ -157,7 +204,7 @@ class DatabaseEloquentCollectionTest extends TestCase public function testLoadMethodEagerLoadsGivenRelationships() { - $c = $this->getMockBuilder(Collection::class)->setMethods(['first'])->setConstructorArgs([['foo']])->getMock(); + $c = $this->getMockBuilder(Collection::class)->onlyMethods(['first'])->setConstructorArgs([['foo']])->getMock(); $mockItem = m::mock(stdClass::class); $c->expects($this->once())->method('first')->willReturn($mockItem); $mockItem->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($mockItem); @@ -276,10 +323,10 @@ class DatabaseEloquentCollectionTest extends TestCase public function testCollectionReturnsDuplicateBasedOnlyOnKeys() { - $one = new TestEloquentCollectionModel(); - $two = new TestEloquentCollectionModel(); - $three = new TestEloquentCollectionModel(); - $four = new TestEloquentCollectionModel(); + $one = new TestEloquentCollectionModel; + $two = new TestEloquentCollectionModel; + $three = new TestEloquentCollectionModel; + $four = new TestEloquentCollectionModel; $one->id = 1; $one->someAttribute = '1'; $two->id = 1; @@ -344,10 +391,10 @@ class DatabaseEloquentCollectionTest extends TestCase public function testCollectionReturnsUniqueStrictBasedOnKeysOnly() { - $one = new TestEloquentCollectionModel(); - $two = new TestEloquentCollectionModel(); - $three = new TestEloquentCollectionModel(); - $four = new TestEloquentCollectionModel(); + $one = new TestEloquentCollectionModel; + $two = new TestEloquentCollectionModel; + $three = new TestEloquentCollectionModel; + $four = new TestEloquentCollectionModel; $one->id = 1; $one->someAttribute = '1'; $two->id = 1; @@ -415,6 +462,15 @@ class DatabaseEloquentCollectionTest extends TestCase $this->assertEquals([], $c[0]->getHidden()); } + public function testAppendsAddsTestOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->makeVisible('test'); + $c = $c->append('test'); + + $this->assertEquals(['test' => 'test'], $c[0]->toArray()); + } + public function testNonModelRelatedMethods() { $a = new Collection([['foo' => 'bar'], ['foo' => 'baz']]); @@ -454,30 +510,165 @@ class DatabaseEloquentCollectionTest extends TestCase public function testQueueableRelationshipsReturnsOnlyRelationsCommonToAllModels() { // This is needed to prevent loading non-existing relationships on polymorphic model collections (#26126) - $c = new Collection([new class { - public function getQueueableRelations() + $c = new Collection([ + new class { - return ['user']; - } - }, new class { - public function getQueueableRelations() + public function getQueueableRelations() + { + return ['user']; + } + }, + new class { - return ['user', 'comments']; - } - }]); + public function getQueueableRelations() + { + return ['user', 'comments']; + } + }, + ]); $this->assertEquals(['user'], $c->getQueueableRelations()); } + public function testQueueableRelationshipsIgnoreCollectionKeys() + { + $c = new Collection([ + 'foo' => new class + { + public function getQueueableRelations() + { + return []; + } + }, + 'bar' => new class + { + public function getQueueableRelations() + { + return []; + } + }, + ]); + + $this->assertEquals([], $c->getQueueableRelations()); + } + public function testEmptyCollectionStayEmptyOnFresh() { $c = new Collection; $this->assertEquals($c, $c->fresh()); } + + public function testCanConvertCollectionOfModelsToEloquentQueryBuilder() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $c = new Collection([$one, $two]); + + $mocBuilder = m::mock(Builder::class); + $one->shouldReceive('newModelQuery')->once()->andReturn($mocBuilder); + $mocBuilder->shouldReceive('whereKey')->once()->with($c->modelKeys())->andReturn($mocBuilder); + $this->assertInstanceOf(Builder::class, $c->toQuery()); + } + + public function testConvertingEmptyCollectionToQueryThrowsException() + { + $this->expectException(LogicException::class); + + $c = new Collection; + $c->toQuery(); + } + + public function testLoadExistsShouldCastBool() + { + $this->seedData(); + $user = EloquentTestUserModel::with('articles')->first(); + $user->articles->loadExists('comments'); + $commentsExists = $user->articles->pluck('comments_exists')->toArray(); + $this->assertContainsOnly('bool', $commentsExists); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = EloquentTestUserModel::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + EloquentTestArticleModel::query()->insert([ + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ]); + + EloquentTestCommentModel::query()->insert([ + ['article_id' => 1, 'content' => 'Another comment'], + ['article_id' => 2, 'content' => 'Another comment'], + ]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } } class TestEloquentCollectionModel extends Model { protected $visible = ['visible']; protected $hidden = ['hidden']; + + public function getTestAttribute() + { + return 'test'; + } +} + +class EloquentTestUserModel extends Model +{ + protected $table = 'users'; + protected $guarded = []; + public $timestamps = false; + + public function articles() + { + return $this->hasMany(EloquentTestArticleModel::class, 'user_id'); + } +} + +class EloquentTestArticleModel extends Model +{ + protected $table = 'articles'; + protected $guarded = []; + public $timestamps = false; + + public function comments() + { + return $this->hasMany(EloquentTestCommentModel::class, 'article_id'); + } +} + +class EloquentTestCommentModel extends Model +{ + protected $table = 'comments'; + protected $guarded = []; + public $timestamps = false; } diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..52b6971ad3f5e7dcdd44e665f135acc1c9c3e3fc --- /dev/null +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -0,0 +1,698 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Faker\Generator; +use Illuminate\Container\Container; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\CrossJoinSequence; +use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Factories\Sequence; +use Illuminate\Database\Eloquent\Model as Eloquent; +use Illuminate\Tests\Database\Fixtures\Models\Money\Price; +use Mockery; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentFactoryTest extends TestCase +{ + protected function setUp(): void + { + $container = Container::getInstance(); + $container->singleton(Generator::class, function ($app, $parameters) { + return \Faker\Factory::create('en_US'); + }); + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->string('options')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('posts', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->string('title'); + $table->timestamps(); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->foreignId('commentable_id'); + $table->string('commentable_type'); + $table->string('body'); + $table->timestamps(); + }); + + $this->schema()->create('roles', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('role_user', function ($table) { + $table->foreignId('role_id'); + $table->foreignId('user_id'); + $table->string('admin')->default('N'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + Mockery::close(); + + $this->schema()->drop('users'); + + Container::setInstance(null); + } + + public function test_basic_model_can_be_created() + { + $user = FactoryTestUserFactory::new()->create(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->createOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->create(['name' => 'Taylor Otwell']); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + + $users = FactoryTestUserFactory::new()->createMany([ + ['name' => 'Taylor Otwell'], + ['name' => 'Jeffrey Way'], + ]); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + + $users = FactoryTestUserFactory::times(10)->create(); + $this->assertCount(10, $users); + } + + public function test_expanded_closure_attributes_are_resolved_and_passed_to_closures() + { + $user = FactoryTestUserFactory::new()->create([ + 'name' => function () { + return 'taylor'; + }, + 'options' => function ($attributes) { + return $attributes['name'].'-options'; + }, + ]); + + $this->assertSame('taylor-options', $user->options); + } + + public function test_make_creates_unpersisted_model_instance() + { + $user = FactoryTestUserFactory::new()->makeOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->make(['name' => 'Taylor Otwell']); + + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + $this->assertCount(0, FactoryTestUser::all()); + } + + public function test_basic_model_attributes_can_be_created() + { + $user = FactoryTestUserFactory::new()->raw(); + $this->assertIsArray($user); + + $user = FactoryTestUserFactory::new()->raw(['name' => 'Taylor Otwell']); + $this->assertIsArray($user); + $this->assertSame('Taylor Otwell', $user['name']); + } + + public function test_expanded_model_attributes_can_be_created() + { + $post = FactoryTestPostFactory::new()->raw(); + $this->assertIsArray($post); + + $post = FactoryTestPostFactory::new()->raw(['title' => 'Test Title']); + $this->assertIsArray($post); + $this->assertIsInt($post['user_id']); + $this->assertSame('Test Title', $post['title']); + } + + public function test_lazy_model_attributes_can_be_created() + { + $userFunction = FactoryTestUserFactory::new()->lazy(); + $this->assertIsCallable($userFunction); + $this->assertInstanceOf(Eloquent::class, $userFunction()); + + $userFunction = FactoryTestUserFactory::new()->lazy(['name' => 'Taylor Otwell']); + $this->assertIsCallable($userFunction); + + $user = $userFunction(); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + } + + public function test_multiple_model_attributes_can_be_created() + { + $posts = FactoryTestPostFactory::new()->times(10)->raw(); + $this->assertIsArray($posts); + + $this->assertCount(10, $posts); + } + + public function test_after_creating_and_making_callbacks_are_called() + { + $user = FactoryTestUserFactory::new() + ->afterMaking(function ($user) { + $_SERVER['__test.user.making'] = $user; + }) + ->afterCreating(function ($user) { + $_SERVER['__test.user.creating'] = $user; + }) + ->create(); + + $this->assertSame($user, $_SERVER['__test.user.making']); + $this->assertSame($user, $_SERVER['__test.user.creating']); + + unset($_SERVER['__test.user.making'], $_SERVER['__test.user.creating']); + } + + public function test_has_many_relationship() + { + $users = FactoryTestUserFactory::times(10) + ->has( + FactoryTestPostFactory::times(3) + ->state(function ($attributes, $user) { + // Test parent is passed to child state mutations... + $_SERVER['__test.post.state-user'] = $user; + + return []; + }) + // Test parents passed to callback... + ->afterCreating(function ($post, $user) { + $_SERVER['__test.post.creating-post'] = $post; + $_SERVER['__test.post.creating-user'] = $user; + }), + 'posts' + ) + ->create(); + + $this->assertCount(10, FactoryTestUser::all()); + $this->assertCount(30, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestUser::latest()->first()->posts); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.creating-post']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.creating-user']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.state-user']); + + unset($_SERVER['__test.post.creating-post'], $_SERVER['__test.post.creating-user'], $_SERVER['__test.post.state-user']); + } + + public function test_belongs_to_relationship() + { + $posts = FactoryTestPostFactory::times(3) + ->for(FactoryTestUserFactory::new(['name' => 'Taylor Otwell']), 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) { + return $post->user->name === 'Taylor Otwell'; + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_belongs_to_relationship_with_existing_model_instance() + { + $user = FactoryTestUserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = FactoryTestPostFactory::times(3) + ->for($user, 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->user->is($user); + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_belongs_to_relationship_with_existing_model_instance_with_relationship_name_implied_from_model() + { + $user = FactoryTestUserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = FactoryTestPostFactory::times(3) + ->for($user) + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->factoryTestUser->is($user); + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_morph_to_relationship() + { + $posts = FactoryTestCommentFactory::times(3) + ->for(FactoryTestPostFactory::new(['title' => 'Test Title']), 'commentable') + ->create(); + + $this->assertSame('Test Title', FactoryTestPost::first()->title); + $this->assertCount(3, FactoryTestPost::first()->comments); + + $this->assertCount(1, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestComment::all()); + } + + public function test_morph_to_relationship_with_existing_model_instance() + { + $post = FactoryTestPostFactory::new(['title' => 'Test Title'])->create(); + $posts = FactoryTestCommentFactory::times(3) + ->for($post, 'commentable') + ->create(); + + $this->assertSame('Test Title', FactoryTestPost::first()->title); + $this->assertCount(3, FactoryTestPost::first()->comments); + + $this->assertCount(1, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestComment::all()); + } + + public function test_belongs_to_many_relationship() + { + $users = FactoryTestUserFactory::times(3) + ->hasAttached( + FactoryTestRoleFactory::times(3)->afterCreating(function ($role, $user) { + $_SERVER['__test.role.creating-role'] = $role; + $_SERVER['__test.role.creating-user'] = $user; + }), + ['admin' => 'Y'], + 'roles' + ) + ->create(); + + $this->assertCount(9, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->roles); + $this->assertSame('Y', $user->roles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-user']); + + unset($_SERVER['__test.role.creating-role'], $_SERVER['__test.role.creating-user']); + } + + public function test_belongs_to_many_relationship_with_existing_model_instances() + { + $roles = FactoryTestRoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + FactoryTestUserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y'], 'roles') + ->create(); + + $this->assertCount(3, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->roles); + $this->assertSame('Y', $user->roles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + + unset($_SERVER['__test.role.creating-role']); + } + + public function test_belongs_to_many_relationship_with_existing_model_instances_with_relationship_name_implied_from_model() + { + $roles = FactoryTestRoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + FactoryTestUserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y']) + ->create(); + + $this->assertCount(3, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->factoryTestRoles); + $this->assertSame('Y', $user->factoryTestRoles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + + unset($_SERVER['__test.role.creating-role']); + } + + public function test_sequences() + { + $users = FactoryTestUserFactory::times(2)->sequence( + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + )->create(); + + $this->assertSame('Taylor Otwell', $users[0]->name); + $this->assertSame('Abigail Otwell', $users[1]->name); + + $user = FactoryTestUserFactory::new() + ->hasAttached( + FactoryTestRoleFactory::times(4), + new Sequence(['admin' => 'Y'], ['admin' => 'N']), + 'roles' + ) + ->create(); + + $this->assertCount(4, $user->roles); + + $this->assertCount(2, $user->roles->filter(function ($role) { + return $role->pivot->admin === 'Y'; + })); + + $this->assertCount(2, $user->roles->filter(function ($role) { + return $role->pivot->admin === 'N'; + })); + + $users = FactoryTestUserFactory::times(2)->sequence(function ($sequence) { + return ['name' => 'index: '.$sequence->index]; + })->create(); + + $this->assertSame('index: 0', $users[0]->name); + $this->assertSame('index: 1', $users[1]->name); + } + + public function test_cross_join_sequences() + { + $assert = function ($users) { + $assertions = [ + ['first_name' => 'Thomas', 'last_name' => 'Anderson'], + ['first_name' => 'Thomas', 'last_name' => 'Smith'], + ['first_name' => 'Agent', 'last_name' => 'Anderson'], + ['first_name' => 'Agent', 'last_name' => 'Smith'], + ]; + + foreach ($assertions as $key => $assertion) { + $this->assertSame( + $assertion, + $users[$key]->only('first_name', 'last_name'), + ); + } + }; + + $usersByClass = FactoryTestUserFactory::times(4) + ->state( + new CrossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ), + ) + ->make(); + + $assert($usersByClass); + + $usersByMethod = FactoryTestUserFactory::times(4) + ->crossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ) + ->make(); + + $assert($usersByMethod); + } + + public function test_resolve_nested_model_factories() + { + Factory::useNamespace('Factories\\'); + + $resolves = [ + 'App\\Foo' => 'Factories\\FooFactory', + 'App\\Models\\Foo' => 'Factories\\FooFactory', + 'App\\Models\\Nested\\Foo' => 'Factories\\Nested\\FooFactory', + 'App\\Models\\Really\\Nested\\Foo' => 'Factories\\Really\\Nested\\FooFactory', + ]; + + foreach ($resolves as $model => $factory) { + $this->assertEquals($factory, Factory::resolveFactoryName($model)); + } + } + + public function test_resolve_nested_model_name_from_factory() + { + Container::getInstance()->instance(Application::class, $app = Mockery::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('Illuminate\\Tests\\Database\\Fixtures\\'); + + Factory::useNamespace('Illuminate\\Tests\\Database\\Fixtures\\Factories\\'); + + $factory = Price::factory(); + + $this->assertSame(Price::class, $factory->modelName()); + } + + public function test_resolve_non_app_nested_model_factories() + { + Container::getInstance()->instance(Application::class, $app = Mockery::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('Foo\\'); + + Factory::useNamespace('Factories\\'); + + $resolves = [ + 'Foo\\Bar' => 'Factories\\BarFactory', + 'Foo\\Models\\Bar' => 'Factories\\BarFactory', + 'Foo\\Models\\Nested\\Bar' => 'Factories\\Nested\\BarFactory', + 'Foo\\Models\\Really\\Nested\\Bar' => 'Factories\\Really\\Nested\\BarFactory', + ]; + + foreach ($resolves as $model => $factory) { + $this->assertEquals($factory, Factory::resolveFactoryName($model)); + } + } + + public function test_model_has_factory() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $this->assertInstanceOf(FactoryTestUserFactory::class, FactoryTestUser::factory()); + } + + public function test_dynamic_has_and_for_methods() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $user = FactoryTestUserFactory::new()->hasPosts(3)->create(); + + $this->assertCount(3, $user->posts); + + $post = FactoryTestPostFactory::new() + ->forAuthor(['name' => 'Taylor Otwell']) + ->hasComments(2) + ->create(); + + $this->assertInstanceOf(FactoryTestUser::class, $post->author); + $this->assertSame('Taylor Otwell', $post->author->name); + $this->assertCount(2, $post->comments); + } + + public function test_can_be_macroable() + { + $factory = FactoryTestUserFactory::new(); + $factory->macro('getFoo', function () { + return 'Hello World'; + }); + + $this->assertSame('Hello World', $factory->getFoo()); + } + + public function test_factory_can_conditionally_execute_code() + { + FactoryTestUserFactory::new() + ->when(true, function () { + $this->assertTrue(true); + }) + ->when(false, function () { + $this->fail('Unreachable code that has somehow been reached.'); + }) + ->unless(false, function () { + $this->assertTrue(true); + }) + ->unless(true, function () { + $this->fail('Unreachable code that has somehow been reached.'); + }); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class FactoryTestUserFactory extends Factory +{ + protected $model = FactoryTestUser::class; + + public function definition() + { + return [ + 'name' => $this->faker->name, + 'options' => null, + ]; + } +} + +class FactoryTestUser extends Eloquent +{ + use HasFactory; + + protected $table = 'users'; + + public function posts() + { + return $this->hasMany(FactoryTestPost::class, 'user_id'); + } + + public function roles() + { + return $this->belongsToMany(FactoryTestRole::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } + + public function factoryTestRoles() + { + return $this->belongsToMany(FactoryTestRole::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } +} + +class FactoryTestPostFactory extends Factory +{ + protected $model = FactoryTestPost::class; + + public function definition() + { + return [ + 'user_id' => FactoryTestUserFactory::new(), + 'title' => $this->faker->name, + ]; + } +} + +class FactoryTestPost extends Eloquent +{ + protected $table = 'posts'; + + public function user() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function factoryTestUser() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function author() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function comments() + { + return $this->morphMany(FactoryTestComment::class, 'commentable'); + } +} + +class FactoryTestCommentFactory extends Factory +{ + protected $model = FactoryTestComment::class; + + public function definition() + { + return [ + 'commentable_id' => FactoryTestPostFactory::new(), + 'commentable_type' => FactoryTestPost::class, + 'body' => $this->faker->name, + ]; + } +} + +class FactoryTestComment extends Eloquent +{ + protected $table = 'comments'; + + public function commentable() + { + return $this->morphTo(); + } +} + +class FactoryTestRoleFactory extends Factory +{ + protected $model = FactoryTestRole::class; + + public function definition() + { + return [ + 'name' => $this->faker->name, + ]; + } +} + +class FactoryTestRole extends Eloquent +{ + protected $table = 'roles'; + + public function users() + { + return $this->belongsToMany(FactoryTestUser::class, 'role_user', 'role_id', 'user_id')->withPivot('admin'); + } +} diff --git a/tests/Database/DatabaseEloquentGlobalScopesTest.php b/tests/Database/DatabaseEloquentGlobalScopesTest.php index ee297eb5be59613b5cf6e4c202aabe11b4c255e3..7ae26071506d8d20886d5a1348c366419a0ff107 100644 --- a/tests/Database/DatabaseEloquentGlobalScopesTest.php +++ b/tests/Database/DatabaseEloquentGlobalScopesTest.php @@ -6,7 +6,6 @@ use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; -use Mockery as m; use PHPUnit\Framework\TestCase; class DatabaseEloquentGlobalScopesTest extends TestCase @@ -16,14 +15,14 @@ class DatabaseEloquentGlobalScopesTest extends TestCase parent::setUp(); tap(new DB)->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ])->bootEloquent(); } protected function tearDown(): void { - m::close(); + parent::tearDown(); Model::unsetConnectionResolver(); } diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index e09b04aaf52c1f87618d87a97cbbd33869143d17..24c302584381f7e780f52d559966c5c02a3be8d0 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -26,6 +26,27 @@ class DatabaseEloquentHasManyTest extends TestCase $this->assertEquals($instance, $relation->make(['name' => 'taylor'])); } + public function testMakeManyCreatesARelatedModelForEachRecord() + { + $records = [ + 'taylor' => ['name' => 'taylor'], + 'colin' => ['name' => 'colin'], + ]; + + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('newCollection')->once()->andReturn(new Collection); + + $taylor = $this->expectNewModel($relation, ['name' => 'taylor']); + $taylor->expects($this->never())->method('save'); + $colin = $this->expectNewModel($relation, ['name' => 'colin']); + $colin->expects($this->never())->method('save'); + + $instances = $relation->makeMany($records); + $this->assertInstanceOf(Collection::class, $instances); + $this->assertEquals($taylor, $instances[0]); + $this->assertEquals($colin, $instances[1]); + } + public function testCreateMethodProperlyCreatesNewModel() { $relation = $this->getRelation(); @@ -34,6 +55,15 @@ class DatabaseEloquentHasManyTest extends TestCase $this->assertEquals($created, $relation->create(['name' => 'taylor'])); } + public function testForceCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + $created = $this->expectForceCreatedModel($relation, ['name' => 'taylor']); + + $this->assertEquals($created, $relation->forceCreate(['name' => 'taylor'])); + $this->assertEquals(1, $created->getAttribute('foreign_key')); + } + public function testFindOrNewMethodFindsModel() { $relation = $this->getRelation(); @@ -269,7 +299,7 @@ class DatabaseEloquentHasManyTest extends TestCase protected function expectNewModel($relation, $attributes = null) { - $model = $this->getMockBuilder(Model::class)->setMethods(['setAttribute', 'save'])->getMock(); + $model = $this->getMockBuilder(Model::class)->onlyMethods(['setAttribute', 'save'])->getMock(); $relation->getRelated()->shouldReceive('newInstance')->with($attributes)->andReturn($model); $model->expects($this->once())->method('setAttribute')->with('foreign_key', 1); @@ -283,6 +313,18 @@ class DatabaseEloquentHasManyTest extends TestCase return $model; } + + protected function expectForceCreatedModel($relation, $attributes) + { + $attributes[$relation->getForeignKeyName()] = $relation->getParentKey(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->with($relation->getForeignKeyName())->andReturn($relation->getParentKey()); + + $relation->getRelated()->shouldReceive('forceCreate')->once()->with($attributes)->andReturn($model); + + return $model; + } } class EloquentHasManyModelStub extends Model diff --git a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php index 244ccd18dfc39c07621a564999368ce2e07d272a..4aef3e4a595fbbf76124287856632d8bf4e0bc7d 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use PHPUnit\Framework\TestCase; @@ -16,8 +17,8 @@ class DatabaseEloquentHasManyThroughIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -120,6 +121,40 @@ class DatabaseEloquentHasManyThroughIntegrationTest extends TestCase $this->assertCount(1, $country); } + public function testFindMethod() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $country = HasManyThroughTestCountry::first(); + $post = $country->posts()->find(1); + + $this->assertNotNull($post); + $this->assertSame('A title', $post->title); + + $this->assertCount(2, $country->posts()->find([1, 2])); + $this->assertCount(2, $country->posts()->find(new Collection([1, 2]))); + } + + public function testFindManyMethod() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $country = HasManyThroughTestCountry::first(); + + $this->assertCount(2, $country->posts()->findMany([1, 2])); + $this->assertCount(2, $country->posts()->findMany(new Collection([1, 2]))); + } + public function testFirstOrFailThrowsAnException() { $this->expectException(ModelNotFoundException::class); @@ -142,6 +177,30 @@ class DatabaseEloquentHasManyThroughIntegrationTest extends TestCase HasManyThroughTestCountry::first()->posts()->findOrFail(1); } + public function testFindOrFailWithManyThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\HasManyThroughTestPost] 1, 2'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->create(['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + + HasManyThroughTestCountry::first()->posts()->findOrFail([1, 2]); + } + + public function testFindOrFailWithManyUsingCollectionThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\HasManyThroughTestPost] 1, 2'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->create(['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + + HasManyThroughTestCountry::first()->posts()->findOrFail(new Collection([1, 2])); + } + public function testFirstRetrievesFirstRecord() { $this->seedData(); @@ -262,6 +321,52 @@ class DatabaseEloquentHasManyThroughIntegrationTest extends TestCase }); } + public function testLazyReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $country->posts()->lazy(10)->each(function ($post) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + } + + public function testLazyById() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $i = 0; + + $country->posts()->lazyById(2)->each(function ($post) use (&$i, &$count) { + $i++; + + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + + $this->assertEquals(6, $i); + } + public function testIntermediateSoftDeletesAreIgnored() { $this->seedData(); diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php new file mode 100755 index 0000000000000000000000000000000000000000..bb6003e73695b31cdfb13fd66fc0cf2c601f5928 --- /dev/null +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -0,0 +1,644 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\SoftDeletes; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentHasOneOfManyTest extends TestCase +{ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('logins', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->dateTime('deleted_at')->nullable(); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->string('state'); + $table->string('type'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + + $this->schema()->create('prices', function ($table) { + $table->increments('id'); + $table->dateTime('published_at'); + $table->foreignId('user_id'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('logins'); + $this->schema()->drop('states'); + $this->schema()->drop('prices'); + } + + public function testItGuessesRelationName() + { + $user = HasOneOfManyTestUser::make(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName() + { + $model = HasOneOfManyTestModel::make(); + $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + } + + public function testRelationNameCanBeSet() + { + $user = HasOneOfManyTestUser::create(); + + // Using "ofMany" + $relation = $user->latest_login()->ofMany('id', 'max', 'foo'); + $this->assertSame('foo', $relation->getRelationName()); + + // Using "latestOfMAny" + $relation = $user->latest_login()->latestOfMAny('id', 'bar'); + $this->assertSame('bar', $relation->getRelationName()); + + // Using "oldestOfMAny" + $relation = $user->latest_login()->oldestOfMAny('id', 'baz'); + $this->assertSame('baz', $relation->getRelationName()); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery() + { + $user = HasOneOfManyTestUser::create(); + $relation = $user->latest_login(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id"', $relation->getOneOfManySubQuery()->toSql()); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScope() + { + HasOneOfManyTestLogin::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = HasOneOfManyTestUser::create(); + $relation = $user->latest_login_without_global_scope(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select "logins".* from "logins" inner join (select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id") as "latestOfMany" on "latestOfMany"."id_aggregate" = "logins"."id" and "latestOfMany"."user_id" = "logins"."user_id" where "logins"."user_id" = ? and "logins"."user_id" is not null', $relation->getQuery()->toSql()); + + HasOneOfManyTestLogin::addGlobalScope('test', function ($query) { + }); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScopeWithComplexQuery() + { + HasOneOfManyTestPrice::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = HasOneOfManyTestUser::create(); + $relation = $user->price_without_global_scope(); + $this->assertSame('select "prices".* from "prices" inner join (select max("prices"."id") as "id_aggregate", "prices"."user_id" from "prices" inner join (select max("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" where "published_at" < ? and "prices"."user_id" = ? and "prices"."user_id" is not null group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "published_at" < ? group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."id_aggregate" = "prices"."id" and "price_without_global_scope"."user_id" = "prices"."user_id" where "prices"."user_id" = ? and "prices"."user_id" is not null', $relation->getQuery()->toSql()); + + HasOneOfManyTestPrice::addGlobalScope('test', function ($query) { + }); + } + + public function testQualifyingSubSelectColumn() + { + $user = HasOneOfManyTestUser::create(); + $this->assertSame('latest_login.id', $user->latest_login()->qualifySubSelectColumn('id')); + } + + public function testItFailsWhenUsingInvalidAggregate() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid aggregate [count] used within ofMany relation. Available aggregates: MIN, MAX'); + $user = HasOneOfManyTestUser::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testResultDoesNotHaveAggregateColumn() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertFalse(isset($result->id_aggregate)); + } + + public function testItGetsCorrectResultsUsingShortcutMethod() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testItGetsCorrectResultsUsingShortcutReceivingMultipleColumnsMethod() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testKeyIsAddedToAggregatesWhenMissing() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_without_key_in_aggregates()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testItGetsWithConstraintsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $user->logins()->create(); + + $result = $user->latest_login()->whereKey($previousLogin->getKey())->getResults(); + $this->assertNull($result); + } + + public function testItEagerLoadsCorrectModels() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $user = HasOneOfManyTestUser::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testItJoinsOtherTableInSubQuery() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + + $this->assertNull($user->latest_login_with_foo_state); + + $user->unsetRelation('latest_login_with_foo_state'); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + + $this->assertNotNull($user->latest_login_with_foo_state); + } + + public function testHasNested() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testHasCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $this->assertFalse($user->latest_login()->whereKey($previousLogin->getKey())->exists()); + $this->assertTrue($user->latest_login()->whereKey($latestLogin->getKey())->exists()); + } + + public function testIsMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertFalse($user->latest_login()->is($login1)); + $this->assertTrue($user->latest_login()->is($login2)); + } + + public function testIsNotMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertTrue($user->latest_login()->isNot($login1)); + $this->assertFalse($user->latest_login()->isNot($login2)); + } + + public function testGet() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $latestLogins = $user->latest_login()->get(); + $this->assertCount(1, $latestLogins); + $this->assertSame($latestLogin->id, $latestLogins->first()->id); + + $latestLogins = $user->latest_login()->whereKey($previousLogin->getKey())->get(); + $this->assertCount(0, $latestLogins); + } + + public function testCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $this->assertSame(1, $user->latest_login()->count()); + } + + public function testAggregate() + { + $user = HasOneOfManyTestUser::create(); + $firstLogin = $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints() + { + $user = HasOneOfManyTestUser::create(); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + $currentForState = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + ]); + $user->states()->create([ + 'type' => 'bar', + 'state' => 'baz', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates() + { + $user = HasOneOfManyTestUser::create(); + + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($price->id, $user->price->id); + } + + public function testEagerLoadingWithMultipleAggregates() + { + $user1 = HasOneOfManyTestUser::create(); + $user2 = HasOneOfManyTestUser::create(); + + $user1->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1Price = $user1->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $user2Price = $user2->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user2->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $users = HasOneOfManyTestUser::with('price')->get(); + + $this->assertNotNull($users[0]->price); + $this->assertSame($user1Price->id, $users[0]->price->id); + + $this->assertNotNull($users[1]->price); + $this->assertSame($user2Price->id, $users[1]->price->id); + } + + public function testWithExists() + { + $user = HasOneOfManyTestUser::create(); + + $user = HasOneOfManyTestUser::withExists('latest_login')->first(); + $this->assertFalse($user->latest_login_exists); + + $user->logins()->create(); + $user = HasOneOfManyTestUser::withExists('latest_login')->first(); + $this->assertTrue($user->latest_login_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $user = HasOneOfManyTestUser::create(); + + $user = HasOneOfManyTestUser::withExists('foo_state')->first(); + + $this->assertFalse($user->foo_state_exists); + + $user->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + ]); + $user = HasOneOfManyTestUser::withExists('foo_state')->first(); + $this->assertTrue($user->foo_state_exists); + } + + public function testWithSoftDeletes() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->latest_login_with_soft_deletes; + $this->assertNotNull($user->latest_login_with_soft_deletes); + } + + public function testWithContraintNotInAggregate() + { + $user = HasOneOfManyTestUser::create(); + + $previousFoo = $user->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + 'updated_at' => '2020-01-01 00:00:00', + ]); + $newFoo = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + $newBar = $user->states()->create([ + 'type' => 'bar', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + + $this->assertSame($newFoo->id, $user->last_updated_foo_state->id); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class HasOneOfManyTestUser extends Eloquent +{ + protected $table = 'users'; + protected $guarded = []; + public $timestamps = false; + + public function logins() + { + return $this->hasMany(HasOneOfManyTestLogin::class, 'user_id'); + } + + public function latest_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany(); + } + + public function latest_login_with_soft_deletes() + { + return $this->hasOne(HasOneOfManyTestLoginWithSoftDeletes::class, 'user_id')->ofMany(); + } + + public function latest_login_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->latestOfMany(); + } + + public function latest_login_with_invalid_aggregate() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'count'); + } + + public function latest_login_without_global_scope() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->withoutGlobalScopes()->latestOfMany(); + } + + public function first_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'min'); + } + + public function latest_login_with_foo_state() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany( + ['id' => 'max'], + function ($query) { + $query->join('states', 'states.user_id', 'logins.user_id') + ->where('states.type', 'foo'); + } + ); + } + + public function states() + { + return $this->hasMany(HasOneOfManyTestState::class, 'user_id'); + } + + public function foo_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } + + public function last_updated_foo_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('type', 'foo'); + }); + } + + public function prices() + { + return $this->hasMany(HasOneOfManyTestPrice::class, 'user_id'); + } + + public function price() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function price_without_key_in_aggregates() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany(['published_at' => 'MAX']); + } + + public function price_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->latestOfMany(['published_at', 'id']); + } + + public function price_without_global_scope() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->withoutGlobalScopes()->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } +} + +class HasOneOfManyTestModel extends Eloquent +{ + public function logins() + { + return $this->hasOne(HasOneOfManyTestLogin::class)->ofMany(); + } +} + +class HasOneOfManyTestLogin extends Eloquent +{ + protected $table = 'logins'; + protected $guarded = []; + public $timestamps = false; +} + +class HasOneOfManyTestLoginWithSoftDeletes extends Eloquent +{ + use SoftDeletes; + + protected $table = 'logins'; + protected $guarded = []; + public $timestamps = false; +} + +class HasOneOfManyTestState extends Eloquent +{ + protected $table = 'states'; + protected $guarded = []; + public $timestamps = true; + protected $fillable = ['type', 'state', 'updated_at']; +} + +class HasOneOfManyTestPrice extends Eloquent +{ + protected $table = 'prices'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['published_at']; + protected $casts = ['published_at' => 'datetime']; +} diff --git a/tests/Database/DatabaseEloquentHasOneTest.php b/tests/Database/DatabaseEloquentHasOneTest.php index d854d0a8515810c81c5b0f4eb975f1b9baf20be0..20df9afdee8bc61a402b9a913562eab204232de7 100755 --- a/tests/Database/DatabaseEloquentHasOneTest.php +++ b/tests/Database/DatabaseEloquentHasOneTest.php @@ -99,7 +99,7 @@ class DatabaseEloquentHasOneTest extends TestCase public function testMakeMethodDoesNotSaveNewModel() { $relation = $this->getRelation(); - $instance = $this->getMockBuilder(Model::class)->setMethods(['save', 'newInstance', 'setAttribute'])->getMock(); + $instance = $this->getMockBuilder(Model::class)->onlyMethods(['save', 'newInstance', 'setAttribute'])->getMock(); $relation->getRelated()->shouldReceive('newInstance')->with(['name' => 'taylor'])->andReturn($instance); $instance->expects($this->once())->method('setAttribute')->with('foreign_key', 1); $instance->expects($this->never())->method('save'); @@ -110,7 +110,7 @@ class DatabaseEloquentHasOneTest extends TestCase public function testSaveMethodSetsForeignKeyOnModel() { $relation = $this->getRelation(); - $mockModel = $this->getMockBuilder(Model::class)->setMethods(['save'])->getMock(); + $mockModel = $this->getMockBuilder(Model::class)->onlyMethods(['save'])->getMock(); $mockModel->expects($this->once())->method('save')->willReturn(true); $result = $relation->save($mockModel); @@ -121,7 +121,7 @@ class DatabaseEloquentHasOneTest extends TestCase public function testCreateMethodProperlyCreatesNewModel() { $relation = $this->getRelation(); - $created = $this->getMockBuilder(Model::class)->setMethods(['save', 'getKey', 'setAttribute'])->getMock(); + $created = $this->getMockBuilder(Model::class)->onlyMethods(['save', 'getKey', 'setAttribute'])->getMock(); $created->expects($this->once())->method('save')->willReturn(true); $relation->getRelated()->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturn($created); $created->expects($this->once())->method('setAttribute')->with('foreign_key', 1); @@ -129,6 +129,20 @@ class DatabaseEloquentHasOneTest extends TestCase $this->assertEquals($created, $relation->create(['name' => 'taylor'])); } + public function testForceCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + $attributes = ['name' => 'taylor', $relation->getForeignKeyName() => $relation->getParentKey()]; + + $created = m::mock(Model::class); + $created->shouldReceive('getAttribute')->with($relation->getForeignKeyName())->andReturn($relation->getParentKey()); + + $relation->getRelated()->shouldReceive('forceCreate')->once()->with($attributes)->andReturn($created); + + $this->assertEquals($created, $relation->forceCreate(['name' => 'taylor'])); + $this->assertEquals(1, $created->getAttribute('foreign_key')); + } + public function testRelationIsProperlyInitialized() { $relation = $this->getRelation(); @@ -160,6 +174,14 @@ class DatabaseEloquentHasOneTest extends TestCase $result1->foreign_key = 1; $result2 = new EloquentHasOneModelStub; $result2->foreign_key = 2; + $result3 = new EloquentHasOneModelStub; + $result3->foreign_key = new class + { + public function __toString() + { + return '4'; + } + }; $model1 = new EloquentHasOneModelStub; $model1->id = 1; @@ -167,12 +189,15 @@ class DatabaseEloquentHasOneTest extends TestCase $model2->id = 2; $model3 = new EloquentHasOneModelStub; $model3->id = 3; + $model4 = new EloquentHasOneModelStub; + $model4->id = 4; - $models = $relation->match([$model1, $model2, $model3], new Collection([$result1, $result2]), 'foo'); + $models = $relation->match([$model1, $model2, $model3, $model4], new Collection([$result1, $result2, $result3]), 'foo'); $this->assertEquals(1, $models[0]->foo->foreign_key); $this->assertEquals(2, $models[1]->foo->foreign_key); $this->assertNull($models[2]->foo); + $this->assertEquals('4', $models[3]->foo->foreign_key); } public function testRelationCountQueryCanBeBuilt() @@ -196,6 +221,106 @@ class DatabaseEloquentHasOneTest extends TestCase $relation->getRelationExistenceCountQuery($builder, $builder); } + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithStringRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(2); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection.two'); + + $this->assertFalse($relation->is($model)); + } + protected function getRelation() { $this->builder = m::mock(Builder::class); diff --git a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php index 67b9824f98e3c71ab76101f40d6758b1c0239195..6b9a2e31f61764d0ca5fb14222da7d705c0dfccb 100644 --- a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php @@ -15,8 +15,8 @@ class DatabaseEloquentHasOneThroughIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -234,6 +234,25 @@ class DatabaseEloquentHasOneThroughIntegrationTest extends TestCase }); } + public function testLazyReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = HasOneThroughTestPosition::find(1); + + $position->contract()->lazy()->each(function ($contract) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', ], array_keys($contract->getAttributes())); + }); + } + public function testIntermediateSoftDeletesAreIgnored() { $this->seedData(); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 957652ef0a20bd56a1f4b602ddd80bd1e8211c16..f46d5e55b74cd5335503913338b8807c14514c2f 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Database; +use DateTimeInterface; use Exception; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Builder; @@ -9,19 +10,21 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\QueryException; use Illuminate\Pagination\AbstractPaginator as Paginator; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Date; use Illuminate\Tests\Integration\Database\Fixtures\Post; use Illuminate\Tests\Integration\Database\Fixtures\User; -use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use RuntimeException; class DatabaseEloquentIntegrationTest extends TestCase { @@ -35,13 +38,13 @@ class DatabaseEloquentIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ], 'second_connection'); $db->bootEloquent(); @@ -81,6 +84,7 @@ class DatabaseEloquentIntegrationTest extends TestCase $table->increments('id'); $table->string('name')->nullable(); $table->string('email'); + $table->timestamp('birthday', 6)->nullable(); $table->timestamps(); }); @@ -125,6 +129,18 @@ class DatabaseEloquentIntegrationTest extends TestCase $table->timestamps(); $table->softDeletes(); }); + + $this->schema($connection)->create('tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema($connection)->create('taggables', function ($table) { + $table->integer('tag_id'); + $table->morphs('taggable'); + $table->string('taxonomy')->nullable(); + }); } $this->schema($connection)->create('non_incrementing_users', function ($table) { @@ -287,6 +303,114 @@ class DatabaseEloquentIntegrationTest extends TestCase $this->assertEquals(3, $query->getCountForPagination()); } + public function testCountForPaginationWithGroupingAndSubSelects() + { + $user1 = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + EloquentTestUser::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + EloquentTestUser::create(['id' => 3, 'email' => 'foo@gmail.com']); + EloquentTestUser::create(['id' => 4, 'email' => 'foo@gmail.com']); + + $user1->friends()->create(['id' => 5, 'email' => 'friend@gmail.com']); + + $query = EloquentTestUser::select([ + 'id', + 'friends_count' => EloquentTestUser::whereColumn('friend_id', 'user_id')->count(), + ])->groupBy('email')->getQuery(); + + $this->assertEquals(4, $query->getCountForPagination()); + } + + public function testCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::create($secondParams = ['id' => 2, 'email' => 'abigailotwell@gmail.com']); + EloquentTestUser::create(['id' => 3, 'email' => 'foo@gmail.com']); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + + CursorPaginator::currentCursorResolver(function () use ($secondParams) { + return new Cursor($secondParams); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + $this->assertFalse($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testPreviousCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + EloquentTestUser::create($thirdParams = ['id' => 3, 'email' => 'foo@gmail.com']); + + CursorPaginator::currentCursorResolver(function () use ($thirdParams) { + return new Cursor($thirdParams, false); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElements() + { + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + + Paginator::currentPageResolver(function () { + return new Cursor(['id' => 1]); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = EloquentTestUser::oldest('id')->cursorPaginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + } + + public function testFirstOrNew() + { + $user1 = EloquentTestUser::firstOrNew( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro'] + ); + + $this->assertSame('Nuno Maduro', $user1->name); + } + public function testFirstOrCreate() { $user1 = EloquentTestUser::firstOrCreate(['email' => 'taylorotwell@gmail.com']); @@ -311,6 +435,13 @@ class DatabaseEloquentIntegrationTest extends TestCase $this->assertNotEquals($user3->id, $user1->id); $this->assertSame('abigailotwell@gmail.com', $user3->email); $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = EloquentTestUser::firstOrCreate( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); } public function testUpdateOrCreate() @@ -332,7 +463,7 @@ class DatabaseEloquentIntegrationTest extends TestCase ); $this->assertSame('Mohamed Said', $user3->name); - $this->assertEquals(EloquentTestUser::count(), 2); + $this->assertEquals(2, EloquentTestUser::count()); } public function testUpdateOrCreateOnDifferentConnection() @@ -349,8 +480,8 @@ class DatabaseEloquentIntegrationTest extends TestCase ['name' => 'Mohamed Said'] ); - $this->assertEquals(EloquentTestUser::count(), 1); - $this->assertEquals(EloquentTestUser::on('second_connection')->count(), 2); + $this->assertEquals(1, EloquentTestUser::count()); + $this->assertEquals(2, EloquentTestUser::on('second_connection')->count()); } public function testCheckAndCreateMethodsOnMultiConnections() @@ -420,7 +551,7 @@ class DatabaseEloquentIntegrationTest extends TestCase function (EloquentTestNonIncrementingSecond $user, $i) use (&$users) { $users[] = [$user->name, $i]; }, 2, 'name'); - $this->assertSame([[' First', 0], [' Second', 1], [' Third', 0]], $users); + $this->assertSame([[' First', 0], [' Second', 1], [' Third', 2]], $users); } public function testPluck() @@ -494,6 +625,15 @@ class DatabaseEloquentIntegrationTest extends TestCase EloquentTestUser::findOrFail([1, 2]); } + public function testFindOrFailWithMultipleIdsUsingCollectionThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\EloquentTestUser] 1, 2'); + + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::findOrFail(new Collection([1, 2])); + } + public function testOneToOneRelationship() { $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); @@ -555,6 +695,36 @@ class DatabaseEloquentIntegrationTest extends TestCase $this->assertCount(1, $models); } + public function testFirstOrNewOnHasOneRelationShip() + { + $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrNew(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = EloquentTestUser::create(['email' => 'abigailotwell@gmail.com']); + $post = $user2->post()->create(['name' => 'First Post']); + $post2 = $user2->post()->firstOrNew(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('First Post', $post2->name); + $this->assertSame($post->id, $post2->id); + } + + public function testFirstOrCreateOnHasOneRelationShip() + { + $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrCreate(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = EloquentTestUser::create(['email' => 'abigailotwell@gmail.com']); + $post = $user2->post()->create(['name' => 'First Post']); + $post2 = $user2->post()->firstOrCreate(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('First Post', $post2->name); + $this->assertSame($post->id, $post2->id); + } + public function testHasOnSelfReferencingBelongsToManyRelationship() { $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); @@ -756,9 +926,33 @@ class DatabaseEloquentIntegrationTest extends TestCase public function testHasOnMorphToRelationship() { - $this->expectException(RuntimeException::class); + $post = EloquentTestPost::create(['name' => 'Morph Post', 'user_id' => 1]); + (new EloquentTestPhoto)->imageable()->associate($post)->fill(['name' => 'Morph Photo'])->save(); + + $photos = EloquentTestPhoto::has('imageable')->get(); - EloquentTestPhoto::has('imageable')->get(); + $this->assertEquals(1, $photos->count()); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedWithSoleQuery() + { + $user = EloquentTestUserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $user->friends()->get()->each(function ($friend) { + $this->assertInstanceOf(EloquentTestFriendPivot::class, $friend->pivot); + }); + + $soleFriend = $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); + + $this->assertInstanceOf(EloquentTestFriendPivot::class, $soleFriend->pivot); + } + + public function testBelongsToManyRelationshipMissingModelExceptionWithSoleQueryWorks() + { + $this->expectException(ModelNotFoundException::class); + $user = EloquentTestUserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); } public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkedRequest() @@ -1075,14 +1269,13 @@ class DatabaseEloquentIntegrationTest extends TestCase $array = $model->toArray(); - $this->assertSame('2012-12-04 00:00:00', $array['created_at']); - $this->assertSame('2012-12-05 00:00:00', $array['updated_at']); + $this->assertSame('2012-12-04T00:00:00.000000Z', $array['created_at']); + $this->assertSame('2012-12-05T00:00:00.000000Z', $array['updated_at']); } public function testToArrayIncludesCustomFormattedTimestamps() { - $model = new EloquentTestUser; - $model->setDateFormat('d-m-y'); + $model = new EloquentTestUserWithCustomDateSerialization; $model->setRawAttributes([ 'created_at' => '2012-12-04', @@ -1192,8 +1385,8 @@ class DatabaseEloquentIntegrationTest extends TestCase $defaultConnectionPost = EloquentTestPhoto::with('imageable')->first()->imageable; $secondConnectionPost = EloquentTestPhoto::on('second_connection')->with('imageable')->first()->imageable; - $this->assertEquals($defaultConnectionPost->name, 'Default Connection Post'); - $this->assertEquals($secondConnectionPost->name, 'Second Connection Post'); + $this->assertSame('Default Connection Post', $defaultConnectionPost->name); + $this->assertSame('Second Connection Post', $secondConnectionPost->name); } public function testBelongsToManyCustomPivot() @@ -1229,28 +1422,75 @@ class DatabaseEloquentIntegrationTest extends TestCase public function testFreshMethodOnModel() { $now = Carbon::now(); + $nowSerialized = $now->startOfSecond()->toJSON(); + $nowWithFractionsSerialized = $now->toJSON(); Carbon::setTestNow($now); - $storedUser1 = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); - $storedUser1->newQuery()->update(['email' => 'dev@mathieutu.ovh', 'name' => 'Mathieu TUDISCO']); + $storedUser1 = EloquentTestUser::create([ + 'id' => 1, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $storedUser1->newQuery()->update([ + 'email' => 'dev@mathieutu.ovh', + 'name' => 'Mathieu TUDISCO', + ]); $freshStoredUser1 = $storedUser1->fresh(); - $storedUser2 = EloquentTestUser::create(['id' => 2, 'email' => 'taylorotwell@gmail.com']); + $storedUser2 = EloquentTestUser::create([ + 'id' => 2, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); $storedUser2->newQuery()->update(['email' => 'dev@mathieutu.ovh']); $freshStoredUser2 = $storedUser2->fresh(); - $notStoredUser = new EloquentTestUser(['id' => 3, 'email' => 'taylorotwell@gmail.com']); + $notStoredUser = new EloquentTestUser([ + 'id' => 3, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); $freshNotStoredUser = $notStoredUser->fresh(); - $this->assertEquals(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'created_at' => $now, 'updated_at' => $now], $storedUser1->toArray()); - $this->assertEquals(['id' => 1, 'name' => 'Mathieu TUDISCO', 'email' => 'dev@mathieutu.ovh', 'created_at' => $now, 'updated_at' => $now], $freshStoredUser1->toArray()); + $this->assertEquals([ + 'id' => 1, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $storedUser1->toArray()); + $this->assertEquals([ + 'id' => 1, + 'name' => 'Mathieu TUDISCO', + 'email' => 'dev@mathieutu.ovh', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $freshStoredUser1->toArray()); $this->assertInstanceOf(EloquentTestUser::class, $storedUser1); - $this->assertEquals(['id' => 2, 'email' => 'taylorotwell@gmail.com', 'created_at' => $now, 'updated_at' => $now], $storedUser2->toArray()); - $this->assertEquals(['id' => 2, 'name' => null, 'email' => 'dev@mathieutu.ovh', 'created_at' => $now, 'updated_at' => $now], $freshStoredUser2->toArray()); + $this->assertEquals([ + 'id' => 2, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $storedUser2->toArray()); + $this->assertEquals([ + 'id' => 2, + 'name' => null, + 'email' => 'dev@mathieutu.ovh', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $freshStoredUser2->toArray()); $this->assertInstanceOf(EloquentTestUser::class, $storedUser2); - $this->assertEquals(['id' => 3, 'email' => 'taylorotwell@gmail.com'], $notStoredUser->toArray()); + $this->assertEquals([ + 'id' => 3, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + ], $notStoredUser->toArray()); $this->assertNull($freshNotStoredUser); } @@ -1265,10 +1505,15 @@ class DatabaseEloquentIntegrationTest extends TestCase EloquentTestUser::find(1)->update(['name' => 'Mathieu TUDISCO']); EloquentTestUser::find(2)->update(['email' => 'dev@mathieutu.ovh']); - $this->assertEquals($users->map->fresh(), $users->fresh()); + $this->assertCount(3, $users); + $this->assertNotSame('Mathieu TUDISCO', $users[0]->name); + $this->assertNotSame('dev@mathieutu.ovh', $users[1]->email); + + $refreshedUsers = $users->fresh(); - $users = new Collection; - $this->assertEquals($users->map->fresh(), $users->fresh()); + $this->assertCount(2, $refreshedUsers); + $this->assertSame('Mathieu TUDISCO', $refreshedUsers[0]->name); + $this->assertSame('dev@mathieutu.ovh', $refreshedUsers[1]->email); } public function testTimestampsUsingDefaultDateFormat() @@ -1321,17 +1566,49 @@ class DatabaseEloquentIntegrationTest extends TestCase $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($model->getAttribute('created_at'))); } - public function testTimestampsUsingOldSqlServerDateFormatFailInEdgeCases() + public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing() { - $this->expectException(InvalidArgumentException::class); - $model = new EloquentTestUser; $model->setDateFormat('Y-m-d H:i:s.000'); // Old SQL Server date format $model->setRawAttributes([ 'updated_at' => '2017-11-14 08:23:19.734', ]); - $model->fromDateTime($model->getAttribute('updated_at')); + $date = $model->getAttribute('updated_at'); + $this->assertSame('2017-11-14 08:23:19.734', $date->format('Y-m-d H:i:s.v'), 'the date should contains the precision'); + $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($date), 'the format should trims it'); + // No longer throwing exception since Laravel 7, + // but Date::hasFormat() can be used instead to check date formatting: + $this->assertTrue(Date::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat())); + $this->assertFalse(Date::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat())); + } + + public function testSpecialFormats() + { + $model = new EloquentTestUser; + $model->setDateFormat('!Y-d-m \\Y'); + $model->setRawAttributes([ + 'updated_at' => '2017-05-11 Y', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2017-11-05 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); + + $model->setDateFormat('Y d m|'); + $model->setRawAttributes([ + 'updated_at' => '2020 11 09', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2020-09-11 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); + + $model->setDateFormat('Y d m|*'); + $model->setRawAttributes([ + 'updated_at' => '2020 11 09 foo', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2020-09-11 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); } public function testUpdatingChildModelTouchesParent() @@ -1628,6 +1905,53 @@ class DatabaseEloquentIntegrationTest extends TestCase $this->assertFalse(Model::isIgnoringTouch()); } + public function testPivotsCanBeRefreshed() + { + EloquentTestFriendLevel::create(['id' => 1, 'level' => 'acquaintance']); + EloquentTestFriendLevel::create(['id' => 2, 'level' => 'friend']); + + $user = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['id' => 2, 'email' => 'abigailotwell@gmail.com'], ['friend_level_id' => 1]); + + $pivot = $user->friends[0]->pivot; + + // Simulate a change that happened externally + DB::table('friends')->where('user_id', 1)->where('friend_id', 2)->update([ + 'friend_level_id' => 2, + ]); + + $this->assertInstanceOf(Pivot::class, $freshPivot = $pivot->fresh()); + $this->assertEquals(2, $freshPivot->friend_level_id); + + $this->assertSame($pivot, $pivot->refresh()); + $this->assertEquals(2, $pivot->friend_level_id); + } + + public function testMorphPivotsCanBeRefreshed() + { + $post = EloquentTestPost::create(['name' => 'MorphToMany Post', 'user_id' => 1]); + $post->tags()->create(['id' => 1, 'name' => 'News']); + + $pivot = $post->tags[0]->pivot; + + // Simulate a change that happened externally + DB::table('taggables') + ->where([ + 'taggable_type' => EloquentTestPost::class, + 'taggable_id' => 1, + 'tag_id' => 1, + ]) + ->update([ + 'taxonomy' => 'primary', + ]); + + $this->assertInstanceOf(MorphPivot::class, $freshPivot = $pivot->fresh()); + $this->assertSame('primary', $freshPivot->taxonomy); + + $this->assertSame($pivot, $pivot->refresh()); + $this->assertSame('primary', $pivot->taxonomy); + } + /** * Helpers... */ @@ -1659,6 +1983,7 @@ class DatabaseEloquentIntegrationTest extends TestCase class EloquentTestUser extends Eloquent { protected $table = 'users'; + protected $casts = ['birthday' => 'datetime']; protected $guarded = []; public function friends() @@ -1793,6 +2118,17 @@ class EloquentTestPost extends Eloquent { return $this->belongsTo(self::class, 'parent_id'); } + + public function tags() + { + return $this->morphToMany(EloquentTestTag::class, 'taggable', null, null, 'tag_id')->withPivot('taxonomy'); + } +} + +class EloquentTestTag extends Eloquent +{ + protected $table = 'tags'; + protected $guarded = []; } class EloquentTestFriendLevel extends Eloquent @@ -1819,6 +2155,14 @@ class EloquentTestUserWithStringCastId extends EloquentTestUser ]; } +class EloquentTestUserWithCustomDateSerialization extends EloquentTestUser +{ + protected function serializeDate(DateTimeInterface $date) + { + return $date->format('d-m-y'); + } +} + class EloquentTestOrder extends Eloquent { protected $guarded = []; diff --git a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php index 371e8602754eff949c46329e67a1eab65c580d87..3b8415da0aa2e70213ab117572e93494e67c060e 100644 --- a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php +++ b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php @@ -20,8 +20,8 @@ class DatabaseEloquentIntegrationWithTablePrefixTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); diff --git a/tests/Database/DatabaseEloquentIrregularPluralTest.php b/tests/Database/DatabaseEloquentIrregularPluralTest.php index 9571af0776e0ee935c81cb333c29f259a8e040ad..9ca407db45648cccd65a42557b8a84661c5df389 100644 --- a/tests/Database/DatabaseEloquentIrregularPluralTest.php +++ b/tests/Database/DatabaseEloquentIrregularPluralTest.php @@ -2,9 +2,9 @@ namespace Illuminate\Tests\Database; -use Carbon\Carbon; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; use PHPUnit\Framework\TestCase; class DatabaseEloquentIrregularPluralTest extends TestCase @@ -14,8 +14,8 @@ class DatabaseEloquentIrregularPluralTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -70,7 +70,7 @@ class DatabaseEloquentIrregularPluralTest extends TestCase /** @test */ public function itPluralizesTheTableName() { - $model = new IrregularPluralHuman(); + $model = new IrregularPluralHuman; $this->assertSame('irregular_plural_humans', $model->getTable()); } diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index acb9598ea8c881dc2d11022cd8f91ca4a8a28657..5597accae834dde6ed8136a32356d15b37a1c8be 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -7,11 +7,16 @@ use DateTimeImmutable; use DateTimeInterface; use Exception; use Foo\Bar\EloquentModelNamespacedStub; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Connection; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\ArrayObject; +use Illuminate\Database\Eloquent\Casts\AsArrayObject; +use Illuminate\Database\Eloquent\Casts\AsCollection; +use Illuminate\Database\Eloquent\Casts\AsStringable; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\MassAssignmentException; @@ -24,6 +29,8 @@ use Illuminate\Database\Query\Processors\Processor; use Illuminate\Support\Carbon; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use InvalidArgumentException; use LogicException; use Mockery as m; @@ -66,12 +73,12 @@ class DatabaseEloquentModelTest extends TestCase $model->list_items = ['name' => 'taylor']; $this->assertEquals(['name' => 'taylor'], $model->list_items); $attributes = $model->getAttributes(); - $this->assertEquals(json_encode(['name' => 'taylor']), $attributes['list_items']); + $this->assertSame(json_encode(['name' => 'taylor']), $attributes['list_items']); } public function testSetAttributeWithNumericKey() { - $model = new EloquentDateModelStub(); + $model = new EloquentDateModelStub; $model->setAttribute(0, 'value'); $this->assertEquals([0 => 'value'], $model->getAttributes()); @@ -92,9 +99,19 @@ class DatabaseEloquentModelTest extends TestCase $this->assertTrue($model->isDirty(['foo', 'bar'])); } + public function testIntAndNullComparisonWhenDirty() + { + $model = new EloquentModelCastingStub; + $model->intAttribute = null; + $model->syncOriginal(); + $this->assertFalse($model->isDirty('intAttribute')); + $model->forceFill(['intAttribute' => 0]); + $this->assertTrue($model->isDirty('intAttribute')); + } + public function testFloatAndNullComparisonWhenDirty() { - $model = new EloquentModelCastingStub(); + $model = new EloquentModelCastingStub; $model->floatAttribute = null; $model->syncOriginal(); $this->assertFalse($model->isDirty('floatAttribute')); @@ -131,7 +148,7 @@ class DatabaseEloquentModelTest extends TestCase { $model = new EloquentModelCastingStub; $model->setRawAttributes([ - 'objectAttribute' => '["one", "two", "three"]', + 'objectAttribute' => '["one", "two", "three"]', 'collectionAttribute' => '["one", "two", "three"]', ]); $model->syncOriginal(); @@ -144,6 +161,60 @@ class DatabaseEloquentModelTest extends TestCase $this->assertFalse($model->isDirty('collectionAttribute')); } + public function testDirtyOnCastedArrayObject() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asarrayobjectAttribute' => '{"foo": "bar"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(ArrayObject::class, $model->asarrayobjectAttribute); + $this->assertFalse($model->isDirty('asarrayobjectAttribute')); + + $model->asarrayobjectAttribute = ['foo' => 'bar']; + $this->assertFalse($model->isDirty('asarrayobjectAttribute')); + + $model->asarrayobjectAttribute = ['foo' => 'baz']; + $this->assertTrue($model->isDirty('asarrayobjectAttribute')); + } + + public function testDirtyOnCastedCollection() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'ascollectionAttribute' => '{"foo": "bar"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(BaseCollection::class, $model->ascollectionAttribute); + $this->assertFalse($model->isDirty('ascollectionAttribute')); + + $model->ascollectionAttribute = ['foo' => 'bar']; + $this->assertFalse($model->isDirty('ascollectionAttribute')); + + $model->ascollectionAttribute = ['foo' => 'baz']; + $this->assertTrue($model->isDirty('ascollectionAttribute')); + } + + public function testDirtyOnCastedStringable() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asStringableAttribute' => 'foo bar', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(Stringable::class, $model->asStringableAttribute); + $this->assertFalse($model->isDirty('asStringableAttribute')); + + $model->asStringableAttribute = Str::of('foo bar'); + $this->assertFalse($model->isDirty('asStringableAttribute')); + + $model->asStringableAttribute = Str::of('foo baz'); + $this->assertTrue($model->isDirty('asStringableAttribute')); + } + public function testCleanAttributes() { $model = new EloquentModelStub(['foo' => '1', 'bar' => 2, 'baz' => 3]); @@ -164,12 +235,14 @@ class DatabaseEloquentModelTest extends TestCase // test is equivalent $model = new EloquentModelStub(['castedFloat' => 8 - 6.4]); $model->syncOriginal(); - $this->assertTrue($model->originalIsEquivalent('castedFloat', 1.6)); + $model->castedFloat = 1.6; + $this->assertTrue($model->originalIsEquivalent('castedFloat')); // test is not equivalent $model = new EloquentModelStub(['castedFloat' => 5.6]); $model->syncOriginal(); - $this->assertFalse($model->originalIsEquivalent('castedFloat', 5.5)); + $model->castedFloat = 5.5; + $this->assertFalse($model->originalIsEquivalent('castedFloat')); } public function testCalculatedAttributes() @@ -194,11 +267,11 @@ class DatabaseEloquentModelTest extends TestCase unset($model['table']); $this->assertTrue(isset($model['attributes'])); - $this->assertEquals($model['attributes'], 1); + $this->assertEquals(1, $model['attributes']); $this->assertTrue(isset($model['connection'])); - $this->assertEquals($model['connection'], 2); + $this->assertEquals(2, $model['connection']); $this->assertFalse(isset($model['table'])); - $this->assertEquals($model['table'], null); + $this->assertEquals(null, $model['table']); $this->assertFalse(isset($model['with'])); } @@ -231,6 +304,16 @@ class DatabaseEloquentModelTest extends TestCase $this->assertSame('test', $newInstance->getTable()); } + public function testNewInstanceReturnsNewInstanceWithMergedCasts() + { + $model = new EloquentModelStub; + $model->mergeCasts(['foo' => 'date']); + $newInstance = $model->newInstance(); + + $this->assertArrayHasKey('foo', $newInstance->getCasts()); + $this->assertSame('date', $newInstance->getCasts()['foo']); + } + public function testCreateMethodSavesNewModel() { $_SERVER['__eloquent.saved'] = false; @@ -267,7 +350,27 @@ class DatabaseEloquentModelTest extends TestCase public function testDestroyMethodCallsQueryBuilderCorrectlyWithCollection() { - EloquentModelDestroyStub::destroy(new Collection([1, 2, 3])); + EloquentModelDestroyStub::destroy(new BaseCollection([1, 2, 3])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEloquentCollection() + { + EloquentModelDestroyStub::destroy(new Collection([ + new EloquentModelDestroyStub(['id' => 1]), + new EloquentModelDestroyStub(['id' => 2]), + new EloquentModelDestroyStub(['id' => 3]), + ])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithMultipleArgs() + { + EloquentModelDestroyStub::destroy(1, 2, 3); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEmptyIds() + { + $count = EloquentModelEmptyDestroyStub::destroy([]); + $this->assertSame(0, $count); } public function testWithMethodCallsQueryBuilderCorrectly() @@ -284,6 +387,15 @@ class DatabaseEloquentModelTest extends TestCase $this->assertEmpty($instance->getEagerLoads()); } + public function testWithOnlyMethodLoadsRelationshipCorrectly() + { + $model = new EloquentModelWithoutRelationStub(); + $this->addMockConnection($model); + $instance = $model->newInstance()->newQuery()->withOnly('taylor'); + $this->assertNotNull($instance->getEagerLoads()['taylor']); + $this->assertArrayNotHasKey('foo', $instance->getEagerLoads()); + } + public function testEagerLoadingWithColumns() { $model = new EloquentModelWithoutRelationStub; @@ -304,7 +416,7 @@ class DatabaseEloquentModelTest extends TestCase public function testUpdateProcess() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('where')->once()->with('id', '=', 1); $query->shouldReceive('update')->once()->with(['name' => 'taylor'])->andReturn(1); @@ -327,7 +439,7 @@ class DatabaseEloquentModelTest extends TestCase public function testUpdateProcessDoesntOverrideTimestamps() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('where')->once()->with('id', '=', 1); $query->shouldReceive('update')->once()->with(['created_at' => 'foo', 'updated_at' => 'bar'])->andReturn(1); @@ -346,7 +458,7 @@ class DatabaseEloquentModelTest extends TestCase public function testSaveIsCanceledIfSavingEventReturnsFalse() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); $query = m::mock(Builder::class); $model->expects($this->once())->method('newModelQuery')->willReturn($query); $model->setEventDispatcher($events = m::mock(Dispatcher::class)); @@ -358,7 +470,7 @@ class DatabaseEloquentModelTest extends TestCase public function testUpdateIsCanceledIfUpdatingEventReturnsFalse() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); $query = m::mock(Builder::class); $model->expects($this->once())->method('newModelQuery')->willReturn($query); $model->setEventDispatcher($events = m::mock(Dispatcher::class)); @@ -372,7 +484,7 @@ class DatabaseEloquentModelTest extends TestCase public function testEventsCanBeFiredWithCustomEventObjects() { - $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->setMethods(['newModelQuery'])->getMock(); + $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->onlyMethods(['newModelQuery'])->getMock(); $query = m::mock(Builder::class); $model->expects($this->once())->method('newModelQuery')->willReturn($query); $model->setEventDispatcher($events = m::mock(Dispatcher::class)); @@ -384,7 +496,7 @@ class DatabaseEloquentModelTest extends TestCase public function testUpdateProcessWithoutTimestamps() { - $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'fireModelEvent'])->getMock(); + $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'fireModelEvent'])->getMock(); $model->timestamps = false; $query = m::mock(Builder::class); $query->shouldReceive('where')->once()->with('id', '=', 1); @@ -402,7 +514,7 @@ class DatabaseEloquentModelTest extends TestCase public function testUpdateUsesOldPrimaryKey() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('where')->once()->with('id', '=', 1); $query->shouldReceive('update')->once()->with(['id' => 2, 'foo' => 'bar'])->andReturn(1); @@ -425,7 +537,7 @@ class DatabaseEloquentModelTest extends TestCase public function testTimestampsAreReturnedAsObjects() { - $model = $this->getMockBuilder(EloquentDateModelStub::class)->setMethods(['getDateFormat'])->getMock(); + $model = $this->getMockBuilder(EloquentDateModelStub::class)->onlyMethods(['getDateFormat'])->getMock(); $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d'); $model->setRawAttributes([ 'created_at' => '2012-12-04', @@ -438,7 +550,7 @@ class DatabaseEloquentModelTest extends TestCase public function testTimestampsAreReturnedAsObjectsFromPlainDatesAndTimestamps() { - $model = $this->getMockBuilder(EloquentDateModelStub::class)->setMethods(['getDateFormat'])->getMock(); + $model = $this->getMockBuilder(EloquentDateModelStub::class)->onlyMethods(['getDateFormat'])->getMock(); $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d H:i:s'); $model->setRawAttributes([ 'created_at' => '2012-12-04', @@ -452,7 +564,7 @@ class DatabaseEloquentModelTest extends TestCase public function testTimestampsAreReturnedAsObjectsOnCreate() { $timestamps = [ - 'created_at' =>Carbon::now(), + 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; $model = new EloquentDateModelStub; @@ -535,11 +647,7 @@ class DatabaseEloquentModelTest extends TestCase public function testFromDateTimeMilliseconds() { - if (version_compare(PHP_VERSION, '7.3.0-dev', '<')) { - $this->markTestSkipped('Due to https://bugs.php.net/bug.php?id=75577, proper "v" format support can only works since PHP 7.3.'); - } - - $model = $this->getMockBuilder('Illuminate\Tests\Database\EloquentDateModelStub')->setMethods(['getDateFormat'])->getMock(); + $model = $this->getMockBuilder('Illuminate\Tests\Database\EloquentDateModelStub')->onlyMethods(['getDateFormat'])->getMock(); $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d H:s.vi'); $model->setRawAttributes([ 'created_at' => '2012-12-04 22:59.32130', @@ -551,7 +659,7 @@ class DatabaseEloquentModelTest extends TestCase public function testInsertProcess() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -570,7 +678,7 @@ class DatabaseEloquentModelTest extends TestCase $this->assertEquals(1, $model->id); $this->assertTrue($model->exists); - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insert')->once()->with(['name' => 'taylor']); $query->shouldReceive('getConnection')->once(); @@ -593,7 +701,7 @@ class DatabaseEloquentModelTest extends TestCase public function testInsertIsCanceledIfCreatingEventReturnsFalse() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('getConnection')->once(); $model->expects($this->once())->method('newModelQuery')->willReturn($query); @@ -607,7 +715,7 @@ class DatabaseEloquentModelTest extends TestCase public function testDeleteProperlyDeletesModel() { - $model = $this->getMockBuilder(Model::class)->setMethods(['newModelQuery', 'updateTimestamps', 'touchOwners'])->getMock(); + $model = $this->getMockBuilder(Model::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'touchOwners'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturn($query); $query->shouldReceive('delete')->once(); @@ -620,7 +728,7 @@ class DatabaseEloquentModelTest extends TestCase public function testPushNoRelations() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -637,7 +745,7 @@ class DatabaseEloquentModelTest extends TestCase public function testPushEmptyOneRelation() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -656,7 +764,7 @@ class DatabaseEloquentModelTest extends TestCase public function testPushOneRelation() { - $related1 = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $related1 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'related1'], 'id')->andReturn(2); $query->shouldReceive('getConnection')->once(); @@ -665,7 +773,7 @@ class DatabaseEloquentModelTest extends TestCase $related1->name = 'related1'; $related1->exists = false; - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -687,7 +795,7 @@ class DatabaseEloquentModelTest extends TestCase public function testPushEmptyManyRelation() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -706,7 +814,7 @@ class DatabaseEloquentModelTest extends TestCase public function testPushManyRelation() { - $related1 = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $related1 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'related1'], 'id')->andReturn(2); $query->shouldReceive('getConnection')->once(); @@ -715,7 +823,7 @@ class DatabaseEloquentModelTest extends TestCase $related1->name = 'related1'; $related1->exists = false; - $related2 = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $related2 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'related2'], 'id')->andReturn(3); $query->shouldReceive('getConnection')->once(); @@ -724,7 +832,7 @@ class DatabaseEloquentModelTest extends TestCase $related2->name = 'related2'; $related2->exists = false; - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -953,6 +1061,65 @@ class DatabaseEloquentModelTest extends TestCase $this->assertArrayNotHasKey('age', $array); } + public function testMakeVisibleIf() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(true, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(false, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(function ($model) { + return ! is_null($model->name); + }, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + } + + public function testMakeHiddenIf() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'address' => 'foobar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHiddenIf(true, 'address')->toArray(); + $this->assertArrayNotHasKey('address', $array); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('id', $array); + + $model->makeVisible('address'); + + $array = $model->makeHiddenIf(false, ['name', 'age'])->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHiddenIf(function ($model) { + return ! is_null($model->id); + }, ['name', 'age'])->toArray(); + $this->assertArrayHasKey('address', $array); + $this->assertArrayNotHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + $this->assertArrayHasKey('id', $array); + } + public function testFillable() { $model = new EloquentModelStub; @@ -1035,7 +1202,7 @@ class DatabaseEloquentModelTest extends TestCase public function testFillableOverridesGuarded() { $model = new EloquentModelStub; - $model->guard(['name', 'age']); + $model->guard([]); $model->fillable(['age', 'foo']); $model->fill(['name' => 'foo', 'age' => 'bar', 'foo' => 'bar']); $this->assertFalse(isset($model->name)); @@ -1169,6 +1336,7 @@ class DatabaseEloquentModelTest extends TestCase $this->addMockConnection($model); // $this->morphTo(); + $model->setAttribute('morph_to_stub_type', EloquentModelSaveStub::class); $relation = $model->morphToStub(); $this->assertSame('morph_to_stub_id', $relation->getForeignKeyName()); $this->assertSame('morph_to_stub_type', $relation->getMorphType()); @@ -1532,6 +1700,11 @@ class DatabaseEloquentModelTest extends TestCase $this->assertSame('camelCased', $model->camelCased); $this->assertSame('StudlyCased', $model->StudlyCased); + $this->assertTrue($model->hasAppended('is_admin')); + $this->assertTrue($model->hasAppended('camelCased')); + $this->assertTrue($model->hasAppended('StudlyCased')); + $this->assertFalse($model->hasAppended('not_appended')); + $model->setHidden(['is_admin', 'camelCased', 'StudlyCased']); $this->assertEquals([], $model->toArray()); @@ -1601,7 +1774,7 @@ class DatabaseEloquentModelTest extends TestCase public function testRelationshipTouchOwnersIsPropagated() { - $relation = $this->getMockBuilder(BelongsTo::class)->setMethods(['touch'])->disableOriginalConstructor()->getMock(); + $relation = $this->getMockBuilder(BelongsTo::class)->onlyMethods(['touch'])->disableOriginalConstructor()->getMock(); $relation->expects($this->once())->method('touch'); $model = m::mock(EloquentModelStub::class.'[partner]'); @@ -1618,7 +1791,7 @@ class DatabaseEloquentModelTest extends TestCase public function testRelationshipTouchOwnersIsNotPropagatedIfNoRelationshipResult() { - $relation = $this->getMockBuilder(BelongsTo::class)->setMethods(['touch'])->disableOriginalConstructor()->getMock(); + $relation = $this->getMockBuilder(BelongsTo::class)->onlyMethods(['touch'])->disableOriginalConstructor()->getMock(); $relation->expects($this->once())->method('touch'); $model = m::mock(EloquentModelStub::class.'[partner]'); @@ -1805,6 +1978,18 @@ class DatabaseEloquentModelTest extends TestCase $this->assertNan($model->floatAttribute); } + public function testMergeCastsMergesCasts() + { + $model = new EloquentModelCastingStub; + + $castCount = count($model->getCasts()); + $this->assertArrayNotHasKey('foo', $model->getCasts()); + + $model->mergeCasts(['foo' => 'date']); + $this->assertCount($castCount + 1, $model->getCasts()); + $this->assertArrayHasKey('foo', $model->getCasts()); + } + public function testUpdatingNonExistentModelFails() { $model = new EloquentModelStub; @@ -1846,7 +2031,7 @@ class DatabaseEloquentModelTest extends TestCase public function testIntKeyTypePreserved() { - $model = $this->getMockBuilder(EloquentModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with([], 'id')->andReturn(1); $query->shouldReceive('getConnection')->once(); @@ -1858,7 +2043,7 @@ class DatabaseEloquentModelTest extends TestCase public function testStringKeyTypePreserved() { - $model = $this->getMockBuilder(EloquentKeyTypeModelStub::class)->setMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $model = $this->getMockBuilder(EloquentKeyTypeModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); $query->shouldReceive('insertGetId')->once()->with([], 'id')->andReturn('string id'); $query->shouldReceive('getConnection')->once(); @@ -1873,10 +2058,13 @@ class DatabaseEloquentModelTest extends TestCase $model = new EloquentModelStub; $this->addMockConnection($model); + Carbon::setTestNow(); + $scopes = [ 'published', 'category' => 'Laravel', 'framework' => ['Laravel', '5.3'], + 'date' => Carbon::now(), ]; $this->assertInstanceOf(Builder::class, $model->scopes($scopes)); @@ -1965,6 +2153,7 @@ class DatabaseEloquentModelTest extends TestCase $model->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); $connection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getBitwiseOperators')->andReturn([]); $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { return new BaseBuilder($connection, $grammar, $processor); @@ -1991,6 +2180,87 @@ class DatabaseEloquentModelTest extends TestCase Model::isIgnoringTouch(EloquentModelWithoutTimestamps::class) ); } + + public function testGetOriginalCastsAttributes() + { + $model = new EloquentModelCastingStub; + $model->intAttribute = '1'; + $model->floatAttribute = '0.1234'; + $model->stringAttribute = 432; + $model->boolAttribute = '1'; + $model->booleanAttribute = '0'; + $stdClass = new stdClass; + $stdClass->json_key = 'json_value'; + $model->objectAttribute = $stdClass; + $array = [ + 'foo' => 'bar', + ]; + $collection = collect($array); + $model->arrayAttribute = $array; + $model->jsonAttribute = $array; + $model->collectionAttribute = $collection; + + $model->syncOriginal(); + + $model->intAttribute = 2; + $model->floatAttribute = 0.443; + $model->stringAttribute = '12'; + $model->boolAttribute = true; + $model->booleanAttribute = false; + $model->objectAttribute = $stdClass; + $model->arrayAttribute = [ + 'foo' => 'bar2', + ]; + $model->jsonAttribute = [ + 'foo' => 'bar2', + ]; + $model->collectionAttribute = collect([ + 'foo' => 'bar2', + ]); + + $this->assertIsInt($model->getOriginal('intAttribute')); + $this->assertEquals(1, $model->getOriginal('intAttribute')); + $this->assertEquals(2, $model->intAttribute); + $this->assertEquals(2, $model->getAttribute('intAttribute')); + + $this->assertIsFloat($model->getOriginal('floatAttribute')); + $this->assertEquals(0.1234, $model->getOriginal('floatAttribute')); + $this->assertEquals(0.443, $model->floatAttribute); + + $this->assertIsString($model->getOriginal('stringAttribute')); + $this->assertSame('432', $model->getOriginal('stringAttribute')); + $this->assertSame('12', $model->stringAttribute); + + $this->assertIsBool($model->getOriginal('boolAttribute')); + $this->assertTrue($model->getOriginal('boolAttribute')); + $this->assertTrue($model->boolAttribute); + + $this->assertIsBool($model->getOriginal('booleanAttribute')); + $this->assertFalse($model->getOriginal('booleanAttribute')); + $this->assertFalse($model->booleanAttribute); + + $this->assertEquals($stdClass, $model->getOriginal('objectAttribute')); + $this->assertEquals($model->getAttribute('objectAttribute'), $model->getOriginal('objectAttribute')); + + $this->assertEquals($array, $model->getOriginal('arrayAttribute')); + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('arrayAttribute')); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('arrayAttribute')); + + $this->assertEquals($array, $model->getOriginal('jsonAttribute')); + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('jsonAttribute')); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('jsonAttribute')); + + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('collectionAttribute')->toArray()); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('collectionAttribute')->toArray()); + } + + public function testUnsavedModel() + { + $user = new UnsavedModel; + $user->name = null; + + $this->assertNull($user->name); + } } class EloquentTestObserverStub @@ -2025,7 +2295,6 @@ class EloquentModelStub extends Model public $scopesCalled = []; protected $table = 'stub'; protected $guarded = []; - protected $morph_to_stub_type = EloquentModelSaveStub::class; protected $casts = ['castedFloat' => 'float']; public function getListItemsAttribute($value) @@ -2112,6 +2381,11 @@ class EloquentModelStub extends Model { $this->scopesCalled['framework'] = [$framework, $version]; } + + public function scopeDate(Builder $builder, Carbon $date) + { + $this->scopesCalled['date'] = $date; + } } trait FooBarTrait @@ -2167,6 +2441,7 @@ class EloquentModelSaveStub extends Model { $mock = m::mock(Connection::class); $mock->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getBitwiseOperators')->andReturn([]); $mock->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); $mock->shouldReceive('getName')->andReturn('name'); $mock->shouldReceive('query')->andReturnUsing(function () use ($mock, $grammar, $processor) { @@ -2196,6 +2471,10 @@ class EloquentModelFindWithWritePdoStub extends Model class EloquentModelDestroyStub extends Model { + protected $fillable = [ + 'id', + ]; + public function newQuery() { $mock = m::mock(Builder::class); @@ -2207,6 +2486,17 @@ class EloquentModelDestroyStub extends Model } } +class EloquentModelEmptyDestroyStub extends Model +{ + public function newQuery() + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('whereIn')->never(); + + return $mock; + } +} + class EloquentModelHydrateRawStub extends Model { public static function hydrate(array $items, $connection = null) @@ -2342,12 +2632,20 @@ class EloquentModelCastingStub extends Model 'dateAttribute' => 'date', 'datetimeAttribute' => 'datetime', 'timestampAttribute' => 'timestamp', + 'asarrayobjectAttribute' => AsArrayObject::class, + 'ascollectionAttribute' => AsCollection::class, + 'asStringableAttribute' => AsStringable::class, ]; public function jsonAttributeValue() { return $this->attributes['jsonAttribute']; } + + protected function serializeDate(DateTimeInterface $date) + { + return $date->format('Y-m-d H:i:s'); + } } class EloquentModelDynamicHiddenStub extends Model @@ -2412,3 +2710,16 @@ class EloquentModelWithUpdatedAtNull extends Model protected $table = 'stub'; const UPDATED_AT = null; } + +class UnsavedModel extends Model +{ + protected $casts = ['name' => Uppercase::class]; +} + +class Uppercase implements CastsInboundAttributes +{ + public function set($model, string $key, $value, array $attributes) + { + return is_string($value) ? strtoupper($value) : $value; + } +} diff --git a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php new file mode 100644 index 0000000000000000000000000000000000000000..244643d2398a7ae43cc1b4f746bb4fe0c5844b7d --- /dev/null +++ b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php @@ -0,0 +1,215 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model as Eloquent; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentMorphOneOfManyTest extends TestCase +{ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('products', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->morphs('stateful'); + $table->string('state'); + $table->string('type')->nullable(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('products'); + $this->schema()->drop('states'); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery() + { + $product = MorphOneOfManyTestProduct::create(); + $relation = $product->current_state(); + $relation->addEagerConstraints([$product]); + $this->assertSame('select MAX("states"."id") as "id_aggregate", "states"."stateful_id", "states"."stateful_type" from "states" where "states"."stateful_type" = ? and "states"."stateful_id" = ? and "states"."stateful_id" is not null and "states"."stateful_id" in (1) and "states"."stateful_type" = ? group by "states"."stateful_id", "states"."stateful_type"', $relation->getOneOfManySubQuery()->toSql()); + } + + public function testReceivingModel() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testMorphType() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + $state = $product->states()->make([ + 'state' => 'foo', + ]); + $state->stateful_type = 'bar'; + $state->save(); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testForceCreateMorphType() + { + $product = MorphOneOfManyTestProduct::create(); + $state = $product->states()->forceCreate([ + 'state' => 'active', + ]); + + $this->assertNotNull($state); + $this->assertSame(MorphOneOfManyTestProduct::class, $product->current_state->stateful_type); + } + + public function testExists() + { + $product = MorphOneOfManyTestProduct::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($currentState) { + $q->whereKey($currentState->getKey()); + })->exists(); + $this->assertTrue($exists); + } + + public function testWithExists() + { + $product = MorphOneOfManyTestProduct::create(); + + $product = MorphOneOfManyTestProduct::withExists('current_state')->first(); + $this->assertFalse($product->current_state_exists); + + $product->states()->create([ + 'state' => 'draft', + ]); + $product = MorphOneOfManyTestProduct::withExists('current_state')->first(); + $this->assertTrue($product->current_state_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $product = MorphOneOfManyTestProduct::create(); + + $product = MorphOneOfManyTestProduct::withExists('current_foo_state')->first(); + $this->assertFalse($product->current_foo_state_exists); + + $product->states()->create([ + 'state' => 'draft', + 'type' => 'foo', + ]); + $product = MorphOneOfManyTestProduct::withExists('current_foo_state')->first(); + $this->assertTrue($product->current_foo_state_exists); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class MorphOneOfManyTestProduct extends Eloquent +{ + protected $table = 'products'; + protected $guarded = []; + public $timestamps = false; + + public function states() + { + return $this->morphMany(MorphOneOfManyTestState::class, 'stateful'); + } + + public function current_state() + { + return $this->morphOne(MorphOneOfManyTestState::class, 'stateful')->ofMany(); + } + + public function current_foo_state() + { + return $this->morphOne(MorphOneOfManyTestState::class, 'stateful')->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } +} + +class MorphOneOfManyTestState extends Eloquent +{ + protected $table = 'states'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['state', 'type']; +} diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index 226cf4f60bf5726ba727680df9fbdeb93f8d09e3..cf7c8352a4de9b5700ee16cbf017ef8d906edd93 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -253,6 +253,106 @@ class DatabaseEloquentMorphTest extends TestCase $this->assertEquals($created, $relation->create(['name' => 'taylor'])); } + public function testIsNotNull() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithStringRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(2); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection.two'); + + $this->assertFalse($relation->is($model)); + } + protected function getOneRelation() { $builder = m::mock(Builder::class); diff --git a/tests/Database/DatabaseEloquentMorphToManyTest.php b/tests/Database/DatabaseEloquentMorphToManyTest.php index 0b4a7f5110740d186864cf5847d2f32b72667c67..32ccbe4b67056c6301f0341ff9b27b85ed585988 100644 --- a/tests/Database/DatabaseEloquentMorphToManyTest.php +++ b/tests/Database/DatabaseEloquentMorphToManyTest.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseEloquentMorphToManyTest extends TestCase { @@ -31,7 +32,7 @@ class DatabaseEloquentMorphToManyTest extends TestCase public function testAttachInsertsPivotTableRecord() { - $relation = $this->getMockBuilder(MorphToMany::class)->setMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); $query = m::mock(stdClass::class); $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); $query->shouldReceive('insert')->once()->with([['taggable_id' => 1, 'taggable_type' => get_class($relation->getParent()), 'tag_id' => 2, 'foo' => 'bar']])->andReturn(true); @@ -44,12 +45,12 @@ class DatabaseEloquentMorphToManyTest extends TestCase public function testDetachRemovesPivotTableRecord() { - $relation = $this->getMockBuilder(MorphToMany::class)->setMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); $query = m::mock(stdClass::class); $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); - $query->shouldReceive('where')->once()->with('taggable_id', 1)->andReturn($query); + $query->shouldReceive('where')->once()->with('taggables.taggable_id', 1)->andReturn($query); $query->shouldReceive('where')->once()->with('taggable_type', get_class($relation->getParent()))->andReturn($query); - $query->shouldReceive('whereIn')->once()->with('tag_id', [1, 2, 3]); + $query->shouldReceive('whereIn')->once()->with('taggables.tag_id', [1, 2, 3]); $query->shouldReceive('delete')->once()->andReturn(true); $relation->getQuery()->shouldReceive('getQuery')->andReturn($mockQueryBuilder = m::mock(stdClass::class)); $mockQueryBuilder->shouldReceive('newQuery')->once()->andReturn($query); @@ -60,10 +61,10 @@ class DatabaseEloquentMorphToManyTest extends TestCase public function testDetachMethodClearsAllPivotRecordsWhenNoIDsAreGiven() { - $relation = $this->getMockBuilder(MorphToMany::class)->setMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); $query = m::mock(stdClass::class); $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); - $query->shouldReceive('where')->once()->with('taggable_id', 1)->andReturn($query); + $query->shouldReceive('where')->once()->with('taggables.taggable_id', 1)->andReturn($query); $query->shouldReceive('where')->once()->with('taggable_type', get_class($relation->getParent()))->andReturn($query); $query->shouldReceive('whereIn')->never(); $query->shouldReceive('delete')->once()->andReturn(true); @@ -97,6 +98,7 @@ class DatabaseEloquentMorphToManyTest extends TestCase $related->shouldReceive('getTable')->andReturn('tags'); $related->shouldReceive('getKeyName')->andReturn('id'); + $related->shouldReceive('qualifyColumn')->with('id')->andReturn('tags.id'); $related->shouldReceive('getMorphClass')->andReturn(get_class($related)); $builder->shouldReceive('join')->once()->with('taggables', 'tags.id', '=', 'taggables.tag_id'); diff --git a/tests/Database/DatabaseEloquentMorphToTest.php b/tests/Database/DatabaseEloquentMorphToTest.php index c01dfd8b924f767f97e8f68a2f04b9fa3fded3bd..6dc6b644887ccc116af3a3bf933b888c80aeb73a 100644 --- a/tests/Database/DatabaseEloquentMorphToTest.php +++ b/tests/Database/DatabaseEloquentMorphToTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Tests\Database\stubs\TestEnum; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -19,13 +20,43 @@ class DatabaseEloquentMorphToTest extends TestCase m::close(); } + public function testLookupDictionaryIsProperlyConstructedForEnums() + { + if (version_compare(PHP_VERSION, '8.1') < 0) { + $this->markTestSkipped('PHP 8.1 is required'); + } else { + $relation = $this->getRelation(); + $relation->addEagerConstraints([ + $one = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => TestEnum::test], + ]); + $dictionary = $relation->getDictionary(); + $relation->getDictionary(); + $enumKey = TestEnum::test; + if (isset($enumKey->value)) { + $value = $dictionary['morph_type_2'][$enumKey->value][0]->foreign_key; + $this->assertEquals(TestEnum::test, $value); + } else { + $this->fail('An enum should contain value property'); + } + } + } + public function testLookupDictionaryIsProperlyConstructed() { + $stringish = new class + { + public function __toString() + { + return 'foreign_key_2'; + } + }; + $relation = $this->getRelation(); $relation->addEagerConstraints([ $one = (object) ['morph_type' => 'morph_type_1', 'foreign_key' => 'foreign_key_1'], $two = (object) ['morph_type' => 'morph_type_1', 'foreign_key' => 'foreign_key_1'], $three = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => 'foreign_key_2'], + $four = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => $stringish], ]); $dictionary = $relation->getDictionary(); @@ -40,6 +71,7 @@ class DatabaseEloquentMorphToTest extends TestCase 'morph_type_2' => [ 'foreign_key_2' => [ $three, + $four, ], ], ], $dictionary); @@ -92,14 +124,24 @@ class DatabaseEloquentMorphToTest extends TestCase public function testMorphToWithZeroMorphType() { - $parent = $this->getMockBuilder(EloquentMorphToModelStub::class)->setMethods(['getAttribute', 'morphEagerTo', 'morphInstanceTo'])->getMock(); - $parent->method('getAttribute')->with('relation_type')->willReturn(0); + $parent = $this->getMockBuilder(EloquentMorphToModelStub::class)->onlyMethods(['getAttributeFromArray', 'morphEagerTo', 'morphInstanceTo'])->getMock(); + $parent->method('getAttributeFromArray')->with('relation_type')->willReturn(0); $parent->expects($this->once())->method('morphInstanceTo'); $parent->expects($this->never())->method('morphEagerTo'); $parent->relation(); } + public function testMorphToWithEmptyStringMorphType() + { + $parent = $this->getMockBuilder(EloquentMorphToModelStub::class)->onlyMethods(['getAttributeFromArray', 'morphEagerTo', 'morphInstanceTo'])->getMock(); + $parent->method('getAttributeFromArray')->with('relation_type')->willReturn(''); + $parent->expects($this->once())->method('morphEagerTo'); + $parent->expects($this->never())->method('morphInstanceTo'); + + $parent->relation(); + } + public function testMorphToWithSpecifiedClassDefault() { $parent = new EloquentMorphToModelStub; @@ -117,13 +159,13 @@ class DatabaseEloquentMorphToTest extends TestCase public function testAssociateMethodSetsForeignKeyAndTypeOnModel() { $parent = m::mock(Model::class); - $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + $parent->shouldReceive('getAttribute')->with('foreign_key')->andReturn('foreign.value'); $relation = $this->getRelationAssociate($parent); $associate = m::mock(Model::class); - $associate->shouldReceive('getKey')->once()->andReturn(1); - $associate->shouldReceive('getMorphClass')->once()->andReturn('Model'); + $associate->shouldReceive('getAttribute')->andReturn(1); + $associate->shouldReceive('getMorphClass')->andReturn('Model'); $parent->shouldReceive('setAttribute')->once()->with('foreign_key', 1); $parent->shouldReceive('setAttribute')->once()->with('morph_type', 'Model'); @@ -160,6 +202,169 @@ class DatabaseEloquentMorphToTest extends TestCase $relation->dissociate(); } + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerParentKey() + { + $parent = m::mock(Model::class); + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerRelatedKey() + { + $parent = m::mock(Model::class); + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return a string + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerKeys() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return null + + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value.two'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation.two'); + + $this->assertFalse($relation->is($model)); + } + protected function getRelationAssociate($parent) { $builder = m::mock(Builder::class); diff --git a/tests/Database/DatabaseEloquentPivotTest.php b/tests/Database/DatabaseEloquentPivotTest.php index da4f7850e20d905535afaa8d53ebd6e168b0d754..ad774d7c1a68abcff0e21fa8ffbaf189ea79156e 100755 --- a/tests/Database/DatabaseEloquentPivotTest.php +++ b/tests/Database/DatabaseEloquentPivotTest.php @@ -2,10 +2,15 @@ namespace Illuminate\Tests\Database; +use Illuminate\Database\Connection; +use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Query\Grammars\Grammar; +use Illuminate\Database\Query\Processors\Processor; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseEloquentPivotTest extends TestCase { @@ -18,6 +23,10 @@ class DatabaseEloquentPivotTest extends TestCase { $parent = m::mock(Model::class.'[getConnectionName]'); $parent->shouldReceive('getConnectionName')->twice()->andReturn('connection'); + $parent->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); $parent->getConnection()->getQueryGrammar()->shouldReceive('getDateFormat')->andReturn('Y-m-d H:i:s'); $parent->setDateFormat('Y-m-d H:i:s'); $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar', 'created_at' => '2015-09-12'], 'table', true); @@ -110,7 +119,7 @@ class DatabaseEloquentPivotTest extends TestCase public function testDeleteMethodDeletesModelByKeys() { - $pivot = $this->getMockBuilder(Pivot::class)->setMethods(['newQueryWithoutRelationships'])->getMock(); + $pivot = $this->getMockBuilder(Pivot::class)->onlyMethods(['newQueryWithoutRelationships'])->getMock(); $pivot->setPivotKeys('foreign', 'other'); $pivot->foreign = 'foreign.value'; $pivot->other = 'other.value'; @@ -155,18 +164,18 @@ class DatabaseEloquentPivotTest extends TestCase public function testWithoutRelations() { - $original = new Pivot(); + $original = new Pivot; $original->pivotParent = 'foo'; $original->setRelation('bar', 'baz'); - $this->assertEquals('baz', $original->getRelation('bar')); + $this->assertSame('baz', $original->getRelation('bar')); $pivot = $original->withoutRelations(); $this->assertInstanceOf(Pivot::class, $pivot); $this->assertNotSame($pivot, $original); - $this->assertEquals('foo', $original->pivotParent); + $this->assertSame('foo', $original->pivotParent); $this->assertNull($pivot->pivotParent); $this->assertTrue($original->relationLoaded('bar')); $this->assertFalse($pivot->relationLoaded('bar')); diff --git a/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php b/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php index c2e7712dcc5e360ee3fbeefbb2e42ab19c7bf728..e567bd95fb559ff14338c53cb7e48e6a0db2e985 100644 --- a/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php +++ b/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php @@ -13,8 +13,8 @@ class DatabaseEloquentPolymorphicIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -137,6 +137,31 @@ class DatabaseEloquentPolymorphicIntegrationTest extends TestCase $this->assertTrue($likes[1]->likeable->relationLoaded('comments')); } + public function testItLoadsNestedMorphRelationshipCountsOnDemand() + { + $this->seedData(); + + TestPost::first()->likes()->create([]); + TestComment::first()->likes()->create([]); + + $likes = TestLike::with('likeable.owner')->get()->loadMorphCount('likeable', [ + TestComment::class => ['likes'], + TestPost::class => 'comments', + ]); + + $this->assertTrue($likes[0]->relationLoaded('likeable')); + $this->assertTrue($likes[0]->likeable->relationLoaded('owner')); + $this->assertEquals(2, $likes[0]->likeable->likes_count); + + $this->assertTrue($likes[1]->relationLoaded('likeable')); + $this->assertTrue($likes[1]->likeable->relationLoaded('owner')); + $this->assertEquals(1, $likes[1]->likeable->comments_count); + + $this->assertTrue($likes[2]->relationLoaded('likeable')); + $this->assertTrue($likes[2]->likeable->relationLoaded('owner')); + $this->assertEquals(2, $likes[2]->likeable->likes_count); + } + /** * Helpers... */ diff --git a/tests/Database/DatabaseEloquentRelationTest.php b/tests/Database/DatabaseEloquentRelationTest.php index baf5502562443cc1f8f2184f0dc2efcdd0c6ad9b..b87b32637b094c31ce417045e35509c785a0ac7e 100755 --- a/tests/Database/DatabaseEloquentRelationTest.php +++ b/tests/Database/DatabaseEloquentRelationTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Exception; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -59,7 +60,7 @@ class DatabaseEloquentRelationTest extends TestCase public function testCanDisableParentTouchingForAllModels() { - /** @var EloquentNoTouchingModelStub $related */ + /** @var \Illuminate\Tests\Database\EloquentNoTouchingModelStub $related */ $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); $related->shouldReceive('getUpdatedAtColumn')->never(); $related->shouldReceive('freshTimestampString')->never(); @@ -240,7 +241,7 @@ class DatabaseEloquentRelationTest extends TestCase $original->setRelation('foo', 'baz'); - $this->assertEquals('baz', $original->getRelation('foo')); + $this->assertSame('baz', $original->getRelation('foo')); $model = $original->withoutRelations(); @@ -267,6 +268,28 @@ class DatabaseEloquentRelationTest extends TestCase $result = $relation->foo(); $this->assertSame('foo', $result); } + + public function testRelationResolvers() + { + $model = new EloquentRelationResetModelStub; + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($model); + + EloquentRelationResetModelStub::resolveRelationUsing('customer', function ($model) use ($builder) { + return new EloquentResolverRelationStub($builder, $model); + }); + + $this->assertInstanceOf(EloquentResolverRelationStub::class, $model->customer()); + $this->assertSame(['key' => 'value'], $model->customer); + } + + public function testIsRelationIgnoresAttribute() + { + $model = new EloquentRelationAndAtrributeModelStub; + + $this->assertTrue($model->isRelation('parent')); + $this->assertFalse($model->isRelation('field')); + } } class EloquentRelationResetModelStub extends Model @@ -329,3 +352,33 @@ class EloquentNoTouchingAnotherModelStub extends Model 'id' => 2, ]; } + +class EloquentResolverRelationStub extends EloquentRelationStub +{ + public function getResults() + { + return ['key' => 'value']; + } +} + +class EloquentRelationAndAtrributeModelStub extends Model +{ + protected $table = 'one_more_table'; + + public function field(): Attribute + { + return new Attribute( + function ($value) { + return $value; + }, + function ($value) { + return $value; + }, + ); + } + + public function parent() + { + return $this->belongsTo(self::class); + } +} diff --git a/tests/Integration/Database/EloquentRelationshipsTest.php b/tests/Database/DatabaseEloquentRelationshipsTest.php similarity index 86% rename from tests/Integration/Database/EloquentRelationshipsTest.php rename to tests/Database/DatabaseEloquentRelationshipsTest.php index c059fc3c896039a2c9cbbc92788695cf34d36341..400682e6967c4eb5b812707b5b7e46c80b7e90f8 100644 --- a/tests/Integration/Database/EloquentRelationshipsTest.php +++ b/tests/Database/DatabaseEloquentRelationshipsTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Integration\Database\EloquentRelationshipsTest; +namespace Illuminate\Tests\Database\EloquentRelationshipsTest; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -14,12 +14,9 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\TestCase; -/** - * @group integration - */ -class EloquentRelationshipsTest extends TestCase +class DatabaseEloquentRelationshipsTest extends TestCase { public function testStandardRelationships() { @@ -52,6 +49,31 @@ class EloquentRelationshipsTest extends TestCase $this->assertInstanceOf(CustomMorphToMany::class, $post->tags()); $this->assertInstanceOf(CustomMorphTo::class, $post->postable()); } + + public function testAlwaysUnsetBelongsToRelationWhenReceivedModelId() + { + // create users + $user1 = (new FakeRelationship)->forceFill(['id' => 1]); + $user2 = (new FakeRelationship)->forceFill(['id' => 2]); + + // sync user 1 using Model + $post = new Post; + $post->author()->associate($user1); + $post->syncOriginal(); + + // associate user 2 using Model + $post->author()->associate($user2); + $this->assertTrue($post->isDirty()); + $this->assertTrue($post->relationLoaded('author')); + $this->assertSame($user2, $post->author); + + // associate user 1 using model ID + $post->author()->associate($user1->id); + $this->assertTrue($post->isClean()); + + // we must unset relation even if attributes are clean + $this->assertFalse($post->relationLoaded('author')); + } } class FakeRelationship extends Model diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 8b88d3d4456cb7a91ea3d4e640d79e555a88861a..786c9c35242bef7f10f4651ac57aef9886b84726 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -8,8 +8,10 @@ use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Query\Builder; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; +use Mockery; use PHPUnit\Framework\TestCase; class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase @@ -21,8 +23,8 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -136,12 +138,19 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase return 1; }); + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $query = SoftDeletesTestUser::query(); $this->assertCount(1, $query->paginate(2)->all()); $query = SoftDeletesTestUser::query(); $this->assertCount(1, $query->simplePaginate(2)->all()); + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->cursorPaginate(2)->all()); + $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->increment('id')); $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->decrement('id')); } @@ -181,6 +190,42 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase $this->assertEquals(1, $users->first()->id); } + public function testForceDeleteUpdateExistsProperty() + { + $this->createUsers(); + $user = SoftDeletesTestUser::find(2); + + $this->assertTrue($user->exists); + + $user->forceDelete(); + + $this->assertFalse($user->exists); + } + + public function testForceDeleteDoesntUpdateExistsPropertyIfFailed() + { + $user = new class() extends SoftDeletesTestUser + { + public $exists = true; + + public function newModelQuery() + { + return Mockery::spy(parent::newModelQuery(), function (Mockery\MockInterface $mock) { + $mock->shouldReceive('forceDelete')->andThrow(new \Exception()); + }); + } + }; + + $this->assertTrue($user->exists); + + try { + $user->forceDelete(); + } catch (\Exception $exception) { + } + + $this->assertTrue($user->exists); + } + public function testRestoreRestoresRecords() { $this->createUsers(); @@ -266,7 +311,7 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase $now = Carbon::now(); $this->createUsers(); - /** @var SoftDeletesTestUser $userModel */ + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ $userModel = SoftDeletesTestUser::find(2); $userModel->delete(); $this->assertEquals($now->toDateTimeString(), $userModel->getOriginal('deleted_at')); @@ -281,7 +326,7 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase { $this->createUsers(); - /** @var SoftDeletesTestUser $userModel */ + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ $userModel = SoftDeletesTestUser::find(2); $userModel->delete(); $userModel->restore(); @@ -296,7 +341,7 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase { $this->createUsers(); - /** @var SoftDeletesTestUser $userModel */ + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ $userModel = SoftDeletesTestUser::withTrashed()->find(1); $userModel->restore(); $this->assertEquals($userModel->deleted_at, SoftDeletesTestUser::find(1)->deleted_at); @@ -311,7 +356,7 @@ class DatabaseEloquentSoftDeletesIntegrationTest extends TestCase { $this->createUsers(); - /** @var SoftDeletesTestUser $userModel */ + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ $userModel = SoftDeletesTestUser::find(2); $userModel->email = 'foo@bar.com'; $userModel->delete(); diff --git a/tests/Database/DatabaseEloquentStrictMorphsTest.php b/tests/Database/DatabaseEloquentStrictMorphsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c77a86f4211e1dc0ac3ba7857a78f0a764b1fb16 --- /dev/null +++ b/tests/Database/DatabaseEloquentStrictMorphsTest.php @@ -0,0 +1,65 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\ClassMorphViolationException; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; +use PHPUnit\Framework\TestCase; + +class DatabaseEloquentStrictMorphsTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Relation::requireMorphMap(); + } + + public function testStrictModeThrowsAnExceptionOnClassMap() + { + $this->expectException(ClassMorphViolationException::class); + + $model = TestModel::make(); + + $model->getMorphClass(); + } + + public function testStrictModeDoesNotThrowExceptionWhenMorphMap() + { + $model = TestModel::make(); + + Relation::morphMap([ + 'test' => TestModel::class, + ]); + + $morphName = $model->getMorphClass(); + $this->assertEquals('test', $morphName); + } + + public function testMapsCanBeEnforcedInOneMethod() + { + $model = TestModel::make(); + + Relation::requireMorphMap(false); + + Relation::enforceMorphMap([ + 'test' => TestModel::class, + ]); + + $morphName = $model->getMorphClass(); + $this->assertEquals('test', $morphName); + } + + protected function tearDown(): void + { + parent::tearDown(); + + Relation::morphMap([], false); + Relation::requireMorphMap(false); + } +} + +class TestModel extends Model +{ +} diff --git a/tests/Database/DatabaseMigrationCreatorTest.php b/tests/Database/DatabaseMigrationCreatorTest.php index 72d37f133b972bd3250958a23882492b0542e187..e262443c697e4c79004a7d206d4377e6bb639ffc 100755 --- a/tests/Database/DatabaseMigrationCreatorTest.php +++ b/tests/Database/DatabaseMigrationCreatorTest.php @@ -20,7 +20,9 @@ class DatabaseMigrationCreatorTest extends TestCase $creator = $this->getCreator(); $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); - $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/blank.stub')->andReturn('DummyClass'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.stub')->andReturn('DummyClass'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'CreateBar'); $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); @@ -39,7 +41,9 @@ class DatabaseMigrationCreatorTest extends TestCase }); $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); - $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/update.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.update.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.update.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'CreateBar baz'); $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); @@ -55,7 +59,9 @@ class DatabaseMigrationCreatorTest extends TestCase { $creator = $this->getCreator(); $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); - $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/update.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.update.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.update.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'CreateBar baz'); $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); @@ -67,7 +73,9 @@ class DatabaseMigrationCreatorTest extends TestCase { $creator = $this->getCreator(); $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); - $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/create.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.create.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.create.stub')->andReturn('DummyClass DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'CreateBar baz'); $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); @@ -91,7 +99,11 @@ class DatabaseMigrationCreatorTest extends TestCase protected function getCreator() { $files = m::mock(Filesystem::class); + $customStubs = 'stubs'; - return $this->getMockBuilder(MigrationCreator::class)->setMethods(['getDatePrefix'])->setConstructorArgs([$files])->getMock(); + return $this->getMockBuilder(MigrationCreator::class) + ->onlyMethods(['getDatePrefix']) + ->setConstructorArgs([$files, $customStubs]) + ->getMock(); } } diff --git a/tests/Database/DatabaseMigrationMakeCommandTest.php b/tests/Database/DatabaseMigrationMakeCommandTest.php index 432cdc245d20630d9d6f4b9ab12e8cf1f21acec8..d655a20927ae68dec3f3e4e0fd78c9dc9e631a83 100755 --- a/tests/Database/DatabaseMigrationMakeCommandTest.php +++ b/tests/Database/DatabaseMigrationMakeCommandTest.php @@ -27,7 +27,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $app->useDatabasePath(__DIR__); $command->setLaravel($app); - $creator->shouldReceive('create')->once()->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); $composer->shouldReceive('dumpAutoloads')->once(); $this->runCommand($command, ['name' => 'create_foo']); @@ -42,7 +44,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $app->useDatabasePath(__DIR__); $command->setLaravel($app); - $creator->shouldReceive('create')->once()->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); $this->runCommand($command, ['name' => 'create_foo']); } @@ -56,7 +60,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $app->useDatabasePath(__DIR__); $command->setLaravel($app); - $creator->shouldReceive('create')->once()->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); $this->runCommand($command, ['name' => 'CreateFoo']); } @@ -70,7 +76,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $app->useDatabasePath(__DIR__); $command->setLaravel($app); - $creator->shouldReceive('create')->once()->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); $this->runCommand($command, ['name' => 'create_foo', '--create' => 'users']); } @@ -84,7 +92,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $app->useDatabasePath(__DIR__); $command->setLaravel($app); - $creator->shouldReceive('create')->once()->with('create_users_table', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true); + $creator->shouldReceive('create')->once() + ->with('create_users_table', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_users_table.php'); $this->runCommand($command, ['name' => 'create_users_table']); } @@ -98,7 +108,9 @@ class DatabaseMigrationMakeCommandTest extends TestCase $app = new Application; $command->setLaravel($app); $app->setBasePath('/home/laravel'); - $creator->shouldReceive('create')->once()->with('create_foo', '/home/laravel/vendor/laravel-package/migrations', 'users', true); + $creator->shouldReceive('create')->once() + ->with('create_foo', '/home/laravel/vendor/laravel-package/migrations', 'users', true) + ->andReturn('/home/laravel/vendor/laravel-package/migrations/2021_04_23_110457_create_foo.php'); $this->runCommand($command, ['name' => 'create_foo', '--path' => 'vendor/laravel-package/migrations', '--create' => 'users']); } diff --git a/tests/Database/DatabaseMigrationMigrateCommandTest.php b/tests/Database/DatabaseMigrationMigrateCommandTest.php index 1a7af92f3b57ff44d9ce097a5ead63d59dd6686a..2fdffd062bbf9f29c59d6eaa22dfcf64785f7786 100755 --- a/tests/Database/DatabaseMigrationMigrateCommandTest.php +++ b/tests/Database/DatabaseMigrationMigrateCommandTest.php @@ -2,11 +2,14 @@ namespace Illuminate\Tests\Database; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; +use Illuminate\Database\Events\SchemaLoaded; use Illuminate\Database\Migrations\Migrator; use Illuminate\Foundation\Application; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; @@ -19,12 +22,15 @@ class DatabaseMigrationMigrateCommandTest extends TestCase public function testBasicMigrationsCallMigratorWithProperArguments() { - $command = new MigrateCommand($migrator = m::mock(Migrator::class)); + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); $migrator->shouldReceive('getNotes')->andReturn([]); @@ -33,15 +39,44 @@ class DatabaseMigrationMigrateCommandTest extends TestCase $this->runCommand($command); } + public function testMigrationsCanBeRunWithStoredSchema() + { + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(false); + $migrator->shouldReceive('resolveConnection')->andReturn($connection = m::mock(stdClass::class)); + $connection->shouldReceive('getName')->andReturn('mysql'); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('deleteRepository')->once(); + $connection->shouldReceive('getSchemaState')->andReturn($schemaState = m::mock(stdClass::class)); + $schemaState->shouldReceive('handleOutputUsing')->andReturnSelf(); + $schemaState->shouldReceive('load')->once()->with(__DIR__.'/stubs/schema.sql'); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(SchemaLoaded::class)); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); + $migrator->shouldReceive('getNotes')->andReturn([]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--schema-path' => __DIR__.'/stubs/schema.sql']); + } + public function testMigrationRepositoryCreatedWhenNecessary() { - $params = [$migrator = m::mock(Migrator::class)]; - $command = $this->getMockBuilder(MigrateCommand::class)->setMethods(['call'])->setConstructorArgs($params)->getMock(); + $params = [$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]; + $command = $this->getMockBuilder(MigrateCommand::class)->onlyMethods(['call'])->setConstructorArgs($params)->getMock(); $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); $migrator->shouldReceive('repositoryExists')->once()->andReturn(false); @@ -52,12 +87,15 @@ class DatabaseMigrationMigrateCommandTest extends TestCase public function testTheCommandMayBePretended() { - $command = new MigrateCommand($migrator = m::mock(Migrator::class)); + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => true, 'step' => false]); $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); @@ -67,12 +105,15 @@ class DatabaseMigrationMigrateCommandTest extends TestCase public function testTheDatabaseMayBeSet() { - $command = new MigrateCommand($migrator = m::mock(Migrator::class)); + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with('foo'); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); @@ -82,12 +123,15 @@ class DatabaseMigrationMigrateCommandTest extends TestCase public function testStepMayBeSet() { - $command = new MigrateCommand($migrator = m::mock(Migrator::class)); + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => true]); $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); diff --git a/tests/Database/DatabaseMigrationRefreshCommandTest.php b/tests/Database/DatabaseMigrationRefreshCommandTest.php index bf3413d3e57143686a600e9410b71ef39d560b37..8502fdfe845022488134e636f46d3f7f2e172a54 100755 --- a/tests/Database/DatabaseMigrationRefreshCommandTest.php +++ b/tests/Database/DatabaseMigrationRefreshCommandTest.php @@ -2,10 +2,12 @@ namespace Illuminate\Tests\Database; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Console\Migrations\RefreshCommand; use Illuminate\Database\Console\Migrations\ResetCommand; use Illuminate\Database\Console\Migrations\RollbackCommand; +use Illuminate\Database\Events\DatabaseRefreshed; use Illuminate\Foundation\Application; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -22,9 +24,10 @@ class DatabaseMigrationRefreshCommandTest extends TestCase public function testRefreshCommandCallsCommandsWithProperArguments() { - $command = new RefreshCommand(); + $command = new RefreshCommand; $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, $events = m::mock()); $console = m::mock(ConsoleApplication::class)->makePartial(); $console->__construct(); $command->setLaravel($app); @@ -35,8 +38,9 @@ class DatabaseMigrationRefreshCommandTest extends TestCase $console->shouldReceive('find')->with('migrate:reset')->andReturn($resetCommand); $console->shouldReceive('find')->with('migrate')->andReturn($migrateCommand); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); - $quote = DIRECTORY_SEPARATOR == '\\' ? '"' : "'"; + $quote = DIRECTORY_SEPARATOR === '\\' ? '"' : "'"; $resetCommand->shouldReceive('run')->with(new InputMatcher("--force=1 {$quote}migrate:reset{$quote}"), m::any()); $migrateCommand->shouldReceive('run')->with(new InputMatcher('--force=1 migrate'), m::any()); @@ -45,9 +49,10 @@ class DatabaseMigrationRefreshCommandTest extends TestCase public function testRefreshCommandCallsCommandsWithStep() { - $command = new RefreshCommand(); + $command = new RefreshCommand; $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, $events = m::mock()); $console = m::mock(ConsoleApplication::class)->makePartial(); $console->__construct(); $command->setLaravel($app); @@ -58,8 +63,9 @@ class DatabaseMigrationRefreshCommandTest extends TestCase $console->shouldReceive('find')->with('migrate:rollback')->andReturn($rollbackCommand); $console->shouldReceive('find')->with('migrate')->andReturn($migrateCommand); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); - $quote = DIRECTORY_SEPARATOR == '\\' ? '"' : "'"; + $quote = DIRECTORY_SEPARATOR === '\\' ? '"' : "'"; $rollbackCommand->shouldReceive('run')->with(new InputMatcher("--step=2 --force=1 {$quote}migrate:rollback{$quote}"), m::any()); $migrateCommand->shouldReceive('run')->with(new InputMatcher('--force=1 migrate'), m::any()); diff --git a/tests/Database/DatabaseMigrationRepositoryTest.php b/tests/Database/DatabaseMigrationRepositoryTest.php index 1f8bc60960e1180666ea2f75a05d433279c40643..30a01c24595c9186c431519256e6fce973e61bcc 100755 --- a/tests/Database/DatabaseMigrationRepositoryTest.php +++ b/tests/Database/DatabaseMigrationRepositoryTest.php @@ -35,7 +35,7 @@ class DatabaseMigrationRepositoryTest extends TestCase public function testGetLastMigrationsGetsAllMigrationsWithTheLatestBatchNumber() { - $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->setMethods(['getLastBatchNumber'])->setConstructorArgs([ + $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->onlyMethods(['getLastBatchNumber'])->setConstructorArgs([ $resolver = m::mock(ConnectionResolverInterface::class), 'migrations', ])->getMock(); $repo->expects($this->once())->method('getLastBatchNumber')->willReturn(1); @@ -81,7 +81,7 @@ class DatabaseMigrationRepositoryTest extends TestCase public function testGetNextBatchNumberReturnsLastBatchNumberPlusOne() { - $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->setMethods(['getLastBatchNumber'])->setConstructorArgs([ + $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->onlyMethods(['getLastBatchNumber'])->setConstructorArgs([ m::mock(ConnectionResolverInterface::class), 'migrations', ])->getMock(); $repo->expects($this->once())->method('getLastBatchNumber')->willReturn(1); diff --git a/tests/Database/DatabaseMigrationResetCommandTest.php b/tests/Database/DatabaseMigrationResetCommandTest.php index a405521253a75a85fc1b42f71c35e14a7ec4a716..d69cc8f81dd25393e72c09a77a94151fb257c9c0 100755 --- a/tests/Database/DatabaseMigrationResetCommandTest.php +++ b/tests/Database/DatabaseMigrationResetCommandTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Database; +use Closure; use Illuminate\Database\Console\Migrations\ResetCommand; use Illuminate\Database\Migrations\Migrator; use Illuminate\Foundation\Application; @@ -24,7 +25,9 @@ class DatabaseMigrationResetCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('usingConnection')->once()->with(null, m::type(Closure::class))->andReturnUsing(function ($connection, $callback) { + $callback(); + }); $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('reset')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], false); @@ -39,7 +42,9 @@ class DatabaseMigrationResetCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with('foo'); + $migrator->shouldReceive('usingConnection')->once()->with('foo', m::type(Closure::class))->andReturnUsing(function ($connection, $callback) { + $callback(); + }); $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('reset')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], true); diff --git a/tests/Database/DatabaseMigrationRollbackCommandTest.php b/tests/Database/DatabaseMigrationRollbackCommandTest.php index c277b0f4f9548ef1c63ef191a7a8d30b726198a4..c2570d910f18c9db0de11c90eb9617ca73bb1d15 100755 --- a/tests/Database/DatabaseMigrationRollbackCommandTest.php +++ b/tests/Database/DatabaseMigrationRollbackCommandTest.php @@ -24,7 +24,9 @@ class DatabaseMigrationRollbackCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => 0]); @@ -38,7 +40,9 @@ class DatabaseMigrationRollbackCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with(null); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => 2]); @@ -52,7 +56,9 @@ class DatabaseMigrationRollbackCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with('foo'); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], true); @@ -66,7 +72,9 @@ class DatabaseMigrationRollbackCommandTest extends TestCase $app->useDatabasePath(__DIR__); $command->setLaravel($app); $migrator->shouldReceive('paths')->once()->andReturn([]); - $migrator->shouldReceive('setConnection')->once()->with('foo'); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => true, 'step' => 2]); diff --git a/tests/Database/DatabaseMigratorIntegrationTest.php b/tests/Database/DatabaseMigratorIntegrationTest.php index 141cb7af983f7aeeb1b0dacbfa093d21839ba9cf..92528ed69cb26018fca21e1a686ea7c62909f66c 100644 --- a/tests/Database/DatabaseMigratorIntegrationTest.php +++ b/tests/Database/DatabaseMigratorIntegrationTest.php @@ -28,10 +28,20 @@ class DatabaseMigratorIntegrationTest extends TestCase $this->db = $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'sqlite2'); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'sqlite3'); + $db->setAsGlobal(); $container = new Container; @@ -53,6 +63,13 @@ class DatabaseMigratorIntegrationTest extends TestCase if (! $repository->repositoryExists()) { $repository->createRepository(); } + + $repository2 = new DatabaseMigrationRepository($db->getDatabaseManager(), 'migrations'); + $repository2->setSource('sqlite2'); + + if (! $repository2->repositoryExists()) { + $repository2->createRepository(); + } } protected function tearDown(): void @@ -72,6 +89,55 @@ class DatabaseMigratorIntegrationTest extends TestCase $this->assertTrue(Str::contains($ran[1], 'password_resets')); } + public function testMigrationsDefaultConnectionCanBeChanged() + { + $ran = $this->migrator->usingConnection('sqlite2', function () { + return $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqllite3']); + }); + + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + $this->assertTrue($this->db->schema('sqlite2')->hasTable('users')); + $this->assertTrue($this->db->schema('sqlite2')->hasTable('password_resets')); + $this->assertFalse($this->db->schema('sqlite3')->hasTable('users')); + $this->assertFalse($this->db->schema('sqlite3')->hasTable('password_resets')); + + $this->assertTrue(Str::contains($ran[0], 'users')); + $this->assertTrue(Str::contains($ran[1], 'password_resets')); + } + + public function testMigrationsCanEachDefineConnection() + { + $ran = $this->migrator->run([__DIR__.'/migrations/connection_configured']); + + $this->assertFalse($this->db->schema()->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema()->hasTable('jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('failed_jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('jobs')); + + $this->assertTrue(Str::contains($ran[0], 'failed_jobs')); + $this->assertTrue(Str::contains($ran[1], 'jobs')); + } + + public function testMigratorCannotChangeDefinedMigrationConnection() + { + $ran = $this->migrator->usingConnection('sqlite2', function () { + return $this->migrator->run([__DIR__.'/migrations/connection_configured']); + }); + + $this->assertFalse($this->db->schema()->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema()->hasTable('jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('failed_jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('jobs')); + + $this->assertTrue(Str::contains($ran[0], 'failed_jobs')); + $this->assertTrue(Str::contains($ran[1], 'jobs')); + } + public function testMigrationsCanBeRolledBack() { $this->migrator->run([__DIR__.'/migrations/one']); @@ -170,4 +236,45 @@ class DatabaseMigratorIntegrationTest extends TestCase $this->assertEquals($expected, $migrationsFilesFullPaths); } + + public function testConnectionPriorToMigrationIsNotChangedAfterMigration() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedAfterRollback() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedWhenNoOutstandingMigrationsExist() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedWhenNothingToRollback() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedAfterMigrateReset() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->reset([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } } diff --git a/tests/Database/DatabaseMySqlBuilderTest.php b/tests/Database/DatabaseMySqlBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..464ce75c741ff46dda80de8c6e5676b6cad3a6cf --- /dev/null +++ b/tests/Database/DatabaseMySqlBuilderTest.php @@ -0,0 +1,48 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Connection; +use Illuminate\Database\Schema\Grammars\MySqlGrammar; +use Illuminate\Database\Schema\MySqlBuilder; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class DatabaseMySqlBuilderTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testCreateDatabase() + { + $grammar = new MySqlGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8mb4'); + $connection->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8mb4_unicode_ci'); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database `my_temporary_database` default character set `utf8mb4` default collate `utf8mb4_unicode_ci`' + )->andReturn(true); + + $builder = new MySqlBuilder($connection); + $builder->createDatabase('my_temporary_database'); + } + + public function testDropDatabaseIfExists() + { + $grammar = new MySqlGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists `my_database_a`' + )->andReturn(true); + + $builder = new MySqlBuilder($connection); + + $builder->dropDatabaseIfExists('my_database_a'); + } +} diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index a21891b7bead40edce0f1e4cce2a44983870389f..b478dc68efcde27355b68586e4a2f8bdd2b99f57 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ForeignIdColumnDefinition; use Illuminate\Database\Schema\Grammars\MySqlGrammar; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -46,6 +47,25 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `id` int unsigned not null auto_increment primary key, add `email` varchar(255) not null', $statements[0]); } + public function testAutoIncrementStartingValue() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->increments('id')->startingValue(1000); + $blueprint->string('email'); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + public function testEngineCreateTable() { $blueprint = new Blueprint('users'); @@ -342,6 +362,16 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add index `baz` using hash(`foo`, `bar`)', $statements[0]); } + public function testAddingFulltextIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->fulltext('body'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add fulltext `users_body_fulltext`(`body`)', $statements[0]); + } + public function testAddingSpatialIndex() { $blueprint = new Blueprint('geo'); @@ -362,6 +392,16 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[1]); } + public function testAddingRawIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `raw_index`((function(column)))', $statements[0]); + } + public function testAddingForeignKey() { $blueprint = new Blueprint('users'); @@ -370,6 +410,20 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertCount(1, $statements); $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`)', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnDelete(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on delete cascade', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on update cascade', $statements[0]); } public function testAddingIncrementingID() @@ -392,6 +446,44 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `id` smallint unsigned not null auto_increment primary key', $statements[0]); } + public function testAddingID() + { + $blueprint = new Blueprint('users'); + $blueprint->id(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint('users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table `users` add `foo` bigint unsigned not null, add `company_id` bigint unsigned not null, add `laravel_idea_id` bigint unsigned not null, add `team_id` bigint unsigned not null, add `team_column_id` bigint unsigned not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + public function testAddingBigIncrementingID() { $blueprint = new Blueprint('users'); @@ -422,6 +514,19 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `name` varchar(255) not null after `foo`', $statements[0]); } + public function testAddingMultipleColumnsAfterAnotherColumn() + { + $blueprint = new Blueprint('users'); + $blueprint->after('foo', function ($blueprint) { + $blueprint->string('one'); + $blueprint->string('two'); + }); + $blueprint->string('three'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `one` varchar(255) not null after `foo`, add `two` varchar(255) not null after `one`, add `three` varchar(255) not null', $statements[0]); + } + public function testAddingGeneratedColumn() { $blueprint = new Blueprint('products'); @@ -455,6 +560,16 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `links` add `url` varchar(2083) character set ascii not null, add `url_hash_virtual` varchar(64) character set ascii as (sha2(url, 256)), add `url_hash_stored` varchar(64) character set ascii as (sha2(url, 256)) stored', $statements[0]); } + public function testAddingInvisibleColumn() + { + $blueprint = new Blueprint('users'); + $blueprint->string('secret', 64)->nullable(false)->invisible(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `secret` varchar(64) not null invisible', $statements[0]); + } + public function testAddingString() { $blueprint = new Blueprint('users'); @@ -530,6 +645,17 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `foo` int not null auto_increment primary key', $statements[0]); } + public function testAddingIncrementsWithStartingValues() + { + $blueprint = new Blueprint('users'); + $blueprint->id()->startingValue(1000); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + public function testAddingMediumInteger() { $blueprint = new Blueprint('users'); @@ -705,6 +831,42 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `foo` datetime(1) not null', $statements[0]); } + public function testAddingDateTimeWithDefaultCurrent() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('foo')->useCurrent(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime default CURRENT_TIMESTAMP not null', $statements[0]); + } + + public function testAddingDateTimeWithOnUpdateCurrent() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('foo')->useCurrentOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime on update CURRENT_TIMESTAMP not null', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentAndOnUpdateCurrent() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('foo')->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP not null', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentOnUpdateCurrentAndPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('foo', 3)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(3) default CURRENT_TIMESTAMP(3) on update CURRENT_TIMESTAMP(3) not null', $statements[0]); + } + public function testAddingDateTimeTz() { $blueprint = new Blueprint('users'); @@ -783,6 +945,33 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]); } + public function testAddingTimestampWithDefaultCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamp('created_at', 1)->useCurrent(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) default CURRENT_TIMESTAMP(1) not null', $statements[0]); + } + + public function testAddingTimestampWithOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamp('created_at', 1)->useCurrentOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) on update CURRENT_TIMESTAMP(1) not null', $statements[0]); + } + + public function testAddingTimestampWithDefaultCurrentAndOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamp('created_at', 1)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) default CURRENT_TIMESTAMP(1) on update CURRENT_TIMESTAMP(1) not null', $statements[0]); + } + public function testAddingTimestampTz() { $blueprint = new Blueprint('users'); @@ -858,6 +1047,27 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertSame('alter table `users` add `foo` char(36) not null', $statements[0]); } + public function testAddingForeignUuid() + { + $blueprint = new Blueprint('users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table `users` add `foo` char(36) not null, add `company_id` char(36) not null, add `laravel_idea_id` char(36) not null, add `team_id` char(36) not null, add `team_column_id` char(36) not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + public function testAddingIpAddress() { $blueprint = new Blueprint('users'); @@ -1014,6 +1224,48 @@ class DatabaseMySqlSchemaGrammarTest extends TestCase $this->assertTrue($c); } + public function testCreateDatabase() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_foo'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_foo'); + + $statement = $this->getGrammar()->compileCreateDatabase('my_database_a', $connection); + + $this->assertSame( + 'create database `my_database_a` default character set `utf8mb4_foo` default collate `utf8mb4_unicode_ci_foo`', + $statement + ); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_bar'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_bar'); + + $statement = $this->getGrammar()->compileCreateDatabase('my_database_b', $connection); + + $this->assertSame( + 'create database `my_database_b` default character set `utf8mb4_bar` default collate `utf8mb4_unicode_ci_bar`', + $statement + ); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists `my_database_a`', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists `my_database_b`', + $statement + ); + } + protected function getConnection() { return m::mock(Connection::class); diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6587d31ad2e6100ec6495b59a8a65d96057f1bf5 --- /dev/null +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -0,0 +1,52 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Connection; +use Illuminate\Database\Schema\Grammars\PostgresGrammar; +use Illuminate\Database\Schema\PostgresBuilder; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class DatabasePostgresBuilderTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testCreateDatabase() + { + $grammar = new PostgresGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database "my_temporary_database" encoding "utf8"' + )->andReturn(true); + + $builder = $this->getBuilder($connection); + $builder->createDatabase('my_temporary_database'); + } + + public function testDropDatabaseIfExists() + { + $grammar = new PostgresGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists "my_database_a"' + )->andReturn(true); + + $builder = $this->getBuilder($connection); + + $builder->dropDatabaseIfExists('my_database_a'); + } + + protected function getBuilder($connection) + { + return new PostgresBuilder($connection); + } +} diff --git a/tests/Database/DatabasePostgresSchemaGrammarTest.php b/tests/Database/DatabasePostgresSchemaGrammarTest.php index 9ac0d0bfa9d8f70e95b023468f00f78230a08737..fae92c3eb6ee490e42edbc09c50aa71147158b9a 100755 --- a/tests/Database/DatabasePostgresSchemaGrammarTest.php +++ b/tests/Database/DatabasePostgresSchemaGrammarTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ForeignIdColumnDefinition; use Illuminate\Database\Schema\Grammars\PostgresGrammar; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -36,6 +37,20 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add column "id" serial primary key not null, add column "email" varchar(255) not null', $statements[0]); } + public function testCreateTableWithAutoIncrementStartingValue() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->increments('id')->startingValue(1000); + $blueprint->string('email'); + $blueprint->string('name')->collation('nb_NO.utf8'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame('create table "users" ("id" serial primary key not null, "email" varchar(255) not null, "name" varchar(255) collate "nb_NO.utf8" not null)', $statements[0]); + $this->assertSame('alter sequence users_id_seq restart with 1000', $statements[1]); + } + public function testCreateTableAndCommentColumn() { $blueprint = new Blueprint('users'); @@ -247,6 +262,46 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('create index "baz" on "users" using hash ("foo", "bar")', $statements[0]); } + public function testAddingFulltextIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->fulltext('body'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[0]); + } + + public function testAddingFulltextIndexMultipleColumns() + { + $blueprint = new Blueprint('users'); + $blueprint->fulltext(['body', 'title']); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_title_fulltext" on "users" using gin ((to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")))', $statements[0]); + } + + public function testAddingFulltextIndexWithLanguage() + { + $blueprint = new Blueprint('users'); + $blueprint->fulltext('body')->language('spanish'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'spanish\', "body")))', $statements[0]); + } + + public function testAddingFulltextIndexWithFluency() + { + $blueprint = new Blueprint('users'); + $blueprint->string('body')->fulltext(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[1]); + } + public function testAddingSpatialIndex() { $blueprint = new Blueprint('geo'); @@ -267,6 +322,16 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('create index "geo_coordinates_spatialindex" on "geo" using gist ("coordinates")', $statements[1]); } + public function testAddingRawIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + public function testAddingIncrementingID() { $blueprint = new Blueprint('users'); @@ -297,6 +362,44 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add column "id" serial primary key not null', $statements[0]); } + public function testAddingID() + { + $blueprint = new Blueprint('users'); + $blueprint->id(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" bigserial primary key not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" bigserial primary key not null', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint('users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add column "foo" bigint not null, add column "company_id" bigint not null, add column "laravel_idea_id" bigint not null, add column "team_id" bigint not null, add column "team_column_id" bigint not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + public function testAddingBigIncrementingID() { $blueprint = new Blueprint('users'); @@ -715,6 +818,27 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add column "foo" uuid not null', $statements[0]); } + public function testAddingForeignUuid() + { + $blueprint = new Blueprint('users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table "users" add column "foo" uuid not null, add column "company_id" uuid not null, add column "laravel_idea_id" uuid not null, add column "team_id" uuid not null, add column "team_column_id" uuid not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + public function testAddingGeneratedAs() { $blueprint = new Blueprint('users'); @@ -893,6 +1017,44 @@ class DatabasePostgresSchemaGrammarTest extends TestCase $this->assertSame('alter table "geo" add column "coordinates" geography(multipolygon, 4326) not null', $statements[0]); } + public function testCreateDatabase() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8_foo'); + $statement = $this->getGrammar()->compileCreateDatabase('my_database_a', $connection); + + $this->assertSame( + 'create database "my_database_a" encoding "utf8_foo"', + $statement + ); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8_bar'); + $statement = $this->getGrammar()->compileCreateDatabase('my_database_b', $connection); + + $this->assertSame( + 'create database "my_database_b" encoding "utf8_bar"', + $statement + ); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists "my_database_a"', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists "my_database_b"', + $statement + ); + } + public function testDropAllTablesEscapesTableNames() { $statement = $this->getGrammar()->compileDropAllTables(['alpha', 'beta', 'gamma']); diff --git a/tests/Database/DatabaseProcessorTest.php b/tests/Database/DatabaseProcessorTest.php index 57a1f50d2bde9789240265042d10dfa9646f50ec..57e953aff59768b333519188e3030b9d1c2c2694 100755 --- a/tests/Database/DatabaseProcessorTest.php +++ b/tests/Database/DatabaseProcessorTest.php @@ -38,6 +38,7 @@ class ProcessorTestPDOStub extends PDO // } + #[\ReturnTypeWillChange] public function lastInsertId($sequence = null) { // diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 8607434fea1a8ddcbd6c41b6a5c85c034bbcf609..6c29eaa8efa5735131698ebcc5afe156aac5a45e 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -3,6 +3,7 @@ namespace Illuminate\Tests\Database; use BadMethodCallException; +use Closure; use DateTime; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; @@ -14,8 +15,11 @@ use Illuminate\Database\Query\Grammars\PostgresGrammar; use Illuminate\Database\Query\Grammars\SQLiteGrammar; use Illuminate\Database\Query\Grammars\SqlServerGrammar; use Illuminate\Database\Query\Processors\MySqlProcessor; +use Illuminate\Database\Query\Processors\PostgresProcessor; use Illuminate\Database\Query\Processors\Processor; use Illuminate\Pagination\AbstractPaginator as Paginator; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use InvalidArgumentException; use Mockery as m; @@ -198,13 +202,13 @@ class DatabaseQueryBuilderTest extends TestCase public function testWhenCallbackWithDefault() { $callback = function ($query, $condition) { - $this->assertEquals($condition, 'truthy'); + $this->assertSame('truthy', $condition); $query->where('id', '=', 1); }; $default = function ($query, $condition) { - $this->assertEquals($condition, 0); + $this->assertEquals(0, $condition); $query->where('id', '=', 2); }; @@ -257,13 +261,13 @@ class DatabaseQueryBuilderTest extends TestCase public function testUnlessCallbackWithDefault() { $callback = function ($query, $condition) { - $this->assertEquals($condition, 0); + $this->assertEquals(0, $condition); $query->where('id', '=', 1); }; $default = function ($query, $condition) { - $this->assertEquals($condition, 'truthy'); + $this->assertSame('truthy', $condition); $query->where('id', '=', 2); }; @@ -675,6 +679,24 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([], $builder->getBindings()); } + public function testWhereBetweenColumns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetweenColumns('id', ['users.created_at', 'users.updated_at']); + $this->assertSame('select * from "users" where "id" between "users"."created_at" and "users"."updated_at"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotBetweenColumns('id', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetweenColumns('id', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" between 1 and 2', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + public function testBasicOrWheres() { $builder = $this->getBuilder(); @@ -771,6 +793,14 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([], $builder->getBindings()); } + public function testOrWhereIntegerInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIntegerInRaw('id', ['1a', 2]); + $this->assertSame('select * from "users" where "id" = ? or "id" in (1, 2)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + public function testWhereIntegerNotInRaw() { $builder = $this->getBuilder(); @@ -779,6 +809,14 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([], $builder->getBindings()); } + public function testOrWhereIntegerNotInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIntegerNotInRaw('id', ['1a', 2]); + $this->assertSame('select * from "users" where "id" = ? or "id" not in (1, 2)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + public function testEmptyWhereIntegerInRaw() { $builder = $this->getBuilder(); @@ -821,6 +859,72 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([], $builder->getBindings()); } + public function testWhereFulltextMySql() + { + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World'); + $this->assertSame('select * from `users` where match (`body`) against (? in natural language mode)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['expanded' => true]); + $this->assertSame('select * from `users` where match (`body`) against (? in natural language mode with query expansion)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean']); + $this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'boolean', 'expanded' => true]); + $this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car,Plane'); + $this->assertSame('select * from `users` where match (`body`, `title`) against (? in natural language mode)', $builder->toSql()); + $this->assertEquals(['Car,Plane'], $builder->getBindings()); + } + + public function testWhereFulltextPostgres() + { + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World'); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple']); + $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'plain']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['mode' => 'phrase']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ phraseto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', '+Hello -World', ['mode' => 'websearch']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ websearch_to_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext('body', 'Hello World', ['language' => 'simple', 'mode' => 'plain']); + $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFulltext(['body', 'title'], 'Car Plane'); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Car Plane'], $builder->getBindings()); + } + public function testUnions() { $builder = $this->getBuilder(); @@ -997,6 +1101,22 @@ class DatabaseQueryBuilderTest extends TestCase $builder->from('posts')->union($this->getSqlServerBuilder()->from('videos'))->count(); } + public function testHavingAggregate() + { + $expected = 'select count(*) as aggregate from (select (select `count(*)` from `videos` where `posts`.`id` = `videos`.`post_id`) as `videos_count` from `posts` having `videos_count` > ?) as `temp_table`'; + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [0 => 1], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $builder->from('posts')->selectSub(function ($query) { + $query->from('videos')->select('count(*)')->whereColumn('posts.id', '=', 'videos.post_id'); + }, 'videos_count')->having('videos_count', '>', 1); + $builder->count(); + } + public function testSubSelectWhereIns() { $builder = $this->getBuilder(); @@ -1027,6 +1147,20 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([0 => 1], $builder->getBindings()); } + public function testJsonWhereNullMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNull('items->id'); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is null OR json_type(json_extract(`items`, \'$."id"\')) = \'NULL\')', $builder->toSql()); + } + + public function testJsonWhereNotNullMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotNull('items->id'); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is not null AND json_type(json_extract(`items`, \'$."id"\')) != \'NULL\')', $builder->toSql()); + } + public function testArrayWhereNulls() { $builder = $this->getBuilder(); @@ -1123,6 +1257,69 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals([1, 1, 'news', 'opinion'], $builder->getBindings()); } + public function testOrderBysSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderBy('age', 'desc'); + $this->assertSame('select * from [users] order by [email] asc, [age] desc', $builder->toSql()); + + $builder->orders = null; + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder->orders = []; + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email'); + $this->assertSame('select * from [users] order by [email] asc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderByDesc('name'); + $this->assertSame('select * from [users] order by [name] desc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderByRaw('[age] asc'); + $this->assertSame('select * from [users] order by [age] asc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderByRaw('[age] ? desc', ['foo']); + $this->assertSame('select * from [users] order by [email] asc, [age] ? desc', $builder->toSql()); + $this->assertEquals(['foo'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->skip(25)->take(10)->orderByRaw('[email] desc'); + $this->assertSame('select * from [users] order by [email] desc offset 25 rows fetch next 10 rows only', $builder->toSql()); + } + + public function testReorder() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('name'); + $this->assertSame('select * from "users" order by "name" asc', $builder->toSql()); + $builder->reorder(); + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('name'); + $this->assertSame('select * from "users" order by "name" asc', $builder->toSql()); + $builder->reorder('email', 'desc'); + $this->assertSame('select * from "users" order by "email" desc', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('first'); + $builder->union($this->getBuilder()->select('*')->from('second')); + $builder->orderBy('name'); + $this->assertSame('(select * from "first") union (select * from "second") order by "name" asc', $builder->toSql()); + $builder->reorder(); + $this->assertSame('(select * from "first") union (select * from "second")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderByRaw('?', [true]); + $this->assertEquals([true], $builder->getBindings()); + $builder->reorder(); + $this->assertEquals([], $builder->getBindings()); + } + public function testOrderBySubQueries() { $expected = 'select * from "users" order by (select "created_at" from "logins" where "user_id" = "users"."id" limit 1)'; @@ -1249,6 +1446,14 @@ class DatabaseQueryBuilderTest extends TestCase $builder->select('*')->from('users')->offset(5)->limit(10); $this->assertSame('select * from "users" limit 10 offset 5', $builder->toSql()); + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->limit(null); + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->limit(0); + $this->assertSame('select * from "users" limit 0', $builder->toSql()); + $builder = $this->getBuilder(); $builder->select('*')->from('users')->skip(5)->take(10); $this->assertSame('select * from "users" limit 10 offset 5', $builder->toSql()); @@ -1260,6 +1465,14 @@ class DatabaseQueryBuilderTest extends TestCase $builder = $this->getBuilder(); $builder->select('*')->from('users')->skip(-5)->take(-10); $this->assertSame('select * from "users" offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->skip(null)->take(null); + $this->assertSame('select * from "users" offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->skip(5)->take(null); + $this->assertSame('select * from "users" offset 5', $builder->toSql()); } public function testForPage() @@ -1449,6 +1662,13 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertSame('select * from "tableB" cross join "tableA" on "tableA"."column1" = "tableB"."column2"', $builder->toSql()); } + public function testCrossJoinSubs() + { + $builder = $this->getBuilder(); + $builder->selectRaw('(sale / overall.sales) * 100 AS percent_of_total')->from('sales')->crossJoinSub($this->getBuilder()->selectRaw('SUM(sale) AS sales')->from('sales'), 'overall'); + $this->assertSame('select (sale / overall.sales) * 100 AS percent_of_total from "sales" cross join (select SUM(sale) AS sales from "sales") as "overall"', $builder->toSql()); + } + public function testComplexJoin() { $builder = $this->getBuilder(); @@ -1892,7 +2112,7 @@ class DatabaseQueryBuilderTest extends TestCase $builder = $this->getBuilder(); $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 0]]); $results = $builder->from('users')->doesntExistOr(function () { - throw new RuntimeException(); + throw new RuntimeException; }); $this->assertTrue($results); } @@ -1908,7 +2128,7 @@ class DatabaseQueryBuilderTest extends TestCase $builder = $this->getBuilder(); $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 1]]); $results = $builder->from('users')->existsOr(function () { - throw new RuntimeException(); + throw new RuntimeException; }); $this->assertTrue($results); } @@ -2128,6 +2348,52 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals(1, $result); } + public function testUpsertMethod() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) on duplicate key update `email` = values(`email`), `name` = values(`name`)', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "email" = "excluded"."email", "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "email" = "excluded"."email", "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('merge [users] using (values (?, ?), (?, ?)) [laravel_source] ([email], [name]) on [laravel_source].[email] = [users].[email] when matched then update set [email] = [laravel_source].[email], [name] = [laravel_source].[name] when not matched then insert ([email], [name]) values ([email], [name]);', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + } + + public function testUpsertMethodWithUpdateColumns() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) on duplicate key update `name` = values(`name`)', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('merge [users] using (values (?, ?), (?, ?)) [laravel_source] ([email], [name]) on [laravel_source].[email] = [users].[email] when matched then update set [name] = [laravel_source].[name] when not matched then insert ([email], [name]) values ([email], [name]);', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + } + public function testUpdateMethodWithJoins() { $builder = $this->getBuilder(); @@ -2254,6 +2520,32 @@ class DatabaseQueryBuilderTest extends TestCase $this->assertEquals(1, $result); } + public function testUpdateFromMethodWithJoinsOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "users"."id" = ? and "users"."id" = "orders"."user_id"', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "users"."id" = "orders"."user_id" and "users"."id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "name" = ? and "users"."id" = "orders"."user_id" and "users"."id" = ?', ['foo', 'bar', 'baz', 1])->andReturn(1); + $result = $builder->from('users') + ->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->where('name', 'baz') + ->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + public function testUpdateMethodRespectsRaw() { $builder = $this->getBuilder(); @@ -2336,6 +2628,11 @@ class DatabaseQueryBuilderTest extends TestCase $builder->getConnection()->shouldReceive('delete')->once()->with('delete from [users] where [email] = ?', ['foo'])->andReturn(1); $result = $builder->from('users')->where('email', '=', 'foo')->delete(); $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete top (1) from [users] where [email] = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->orderBy('id')->take(1)->delete(); + $this->assertEquals(1, $result); } public function testDeleteWithJoinMethod() @@ -2426,6 +2723,116 @@ class DatabaseQueryBuilderTest extends TestCase ], $sqlite->compileTruncate($builder)); } + public function testPreserveAddsClosureToArray() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function () { + }); + $this->assertCount(1, $builder->beforeQueryCallbacks); + $this->assertInstanceOf(Closure::class, $builder->beforeQueryCallbacks[0]); + } + + public function testApplyPreserveCleansArray() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function () { + }); + $this->assertCount(1, $builder->beforeQueryCallbacks); + $builder->applyBeforeQueryCallbacks(); + $this->assertCount(0, $builder->beforeQueryCallbacks); + } + + public function testPreservedAreAppliedByToSql() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function ($builder) { + $builder->where('foo', 'bar'); + }); + $this->assertSame('select * where "foo" = ?', $builder->toSql()); + $this->assertEquals(['bar'], $builder->getBindings()); + } + + public function testPreservedAreAppliedByInsert() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('insert')->once()->with('insert into "users" ("email") values (?)', ['foo']); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insert(['email' => 'foo']); + } + + public function testPreservedAreAppliedByInsertGetId() + { + $this->called = false; + $builder = $this->getBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" ("email") values (?)', ['foo'], 'id'); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insertGetId(['email' => 'foo'], 'id'); + } + + public function testPreservedAreAppliedByInsertUsing() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" () select *', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insertUsing([], $this->getBuilder()); + } + + public function testPreservedAreAppliedByUpsert() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`) values (?) on duplicate key update `email` = values(`email`)', ['foo']); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->upsert(['email' => 'foo'], 'id'); + } + + public function testPreservedAreAppliedByUpdate() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ? where "id" = ?', ['foo', 1]); + $builder->from('users')->beforeQuery(function ($builder) { + $builder->where('id', 1); + }); + $builder->update(['email' => 'foo']); + } + + public function testPreservedAreAppliedByDelete() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users"', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->delete(); + } + + public function testPreservedAreAppliedByTruncate() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('statement')->once()->with('truncate table "users"', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->truncate(); + } + + public function testPreservedAreAppliedByExists() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->exists(); + } + public function testPostgresInsertGetId() { $builder = $this->getPostgresBuilder(); @@ -2769,8 +3176,29 @@ SQL; $this->assertSame('select * from (select *, row_number() over (order by (select 0)) as row_num from [users]) as temp_table where row_num between 11 and 20 order by row_num', $builder->toSql()); $builder = $this->getSqlServerBuilder(); - $builder->select('*')->from('users')->skip(10)->take(10)->orderBy('email', 'desc'); - $this->assertSame('select * from (select *, row_number() over (order by [email] desc) as row_num from [users]) as temp_table where row_num between 11 and 20 order by row_num', $builder->toSql()); + $builder->select('*')->from('users')->skip(11)->take(10)->orderBy('email', 'desc'); + $this->assertSame('select * from [users] order by [email] desc offset 11 rows fetch next 10 rows only', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $subQueryBuilder = $this->getSqlServerBuilder(); + $subQuery = function ($query) { + return $query->select('created_at')->from('logins')->where('users.name', 'nameBinding')->whereColumn('user_id', 'users.id')->limit(1); + }; + $builder->select('*')->from('users')->where('email', 'emailBinding')->orderBy($subQuery)->skip(10)->take(10); + $this->assertSame('select * from [users] where [email] = ? order by (select top 1 [created_at] from [logins] where [users].[name] = ? and [user_id] = [users].[id]) asc offset 10 rows fetch next 10 rows only', $builder->toSql()); + $this->assertEquals(['emailBinding', 'nameBinding'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->take('foo'); + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->take('foo')->offset('bar'); + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset('bar'); + $this->assertSame('select * from [users]', $builder->toSql()); } public function testMySqlSoundsLikeOperator() @@ -2781,6 +3209,41 @@ SQL; $this->assertEquals(['John Doe'], $builder->getBindings()); } + public function testBitwiseOperators() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('bar', '&', 1); + $this->assertSame('select * from "users" where "bar" & ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('bar', '#', 1); + $this->assertSame('select * from "users" where ("bar" # ?)::bool', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('range', '>>', '[2022-01-08 00:00:00,2022-01-09 00:00:00)'); + $this->assertSame('select * from "users" where ("range" >> ?)::bool', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('bar', '&', 1); + $this->assertSame('select * from [users] where ([bar] & ?) != 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('bar', '&', 1); + $this->assertSame('select * from "users" having "bar" & ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->having('bar', '#', 1); + $this->assertSame('select * from "users" having ("bar" # ?)::bool', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->having('range', '>>', '[2022-01-08 00:00:00,2022-01-09 00:00:00)'); + $this->assertSame('select * from "users" having ("range" >> ?)::bool', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->having('bar', '&', 1); + $this->assertSame('select * from [users] having ([bar] & ?) != 0', $builder->toSql()); + } + public function testMergeWheresCanMergeWheresAndBindings() { $builder = $this->getBuilder(); @@ -3005,6 +3468,22 @@ SQL; $builder->selectSub(['foo'], 'sub'); } + public function testSubSelectResetBindings() + { + $builder = $this->getPostgresBuilder(); + $builder->from('one')->selectSub(function ($query) { + $query->from('two')->select('baz')->where('subkey', '=', 'subval'); + }, 'sub'); + + $this->assertSame('select (select "baz" from "two" where "subkey" = ?) as "sub" from "one"', $builder->toSql()); + $this->assertEquals(['subval'], $builder->getBindings()); + + $builder->select('*'); + + $this->assertSame('select * from "one"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + public function testSqlServerWhereDate() { $builder = $this->getSqlServerBuilder(); @@ -3321,6 +3800,417 @@ SQL; ]), $result); } + public function testCursorPaginate() + { + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 17', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateMultipleOrderColumns() + { + $perPage = 16; + $columns = ['test', 'another']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar', 'another' => 'foo']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test')->orderBy('another'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo', 'another' => 1], ['test' => 'bar', 'another' => 2]]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ? or ("test" = ? and ("another" > ?))) order by "test" asc, "another" asc limit 17', + $builder->toSql() + ); + $this->assertEquals(['bar', 'bar', 'foo'], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test', 'another'], + ]), $result); + } + + public function testCursorPaginateWithDefaultArguments() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWhenNoResults() + { + $perPage = 15; + $cursorName = 'cursor'; + $builder = $this->getMockQueryBuilder()->orderBy('test'); + $path = 'http://foo.bar?cursor=3'; + + $results = []; + + $builder->shouldReceive('get')->once()->andReturn($results); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, null, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithSpecificColumns() + { + $perPage = 16; + $columns = ['id', 'name']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['id' => 2]); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('id'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor=3'; + + $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("id" > ?) order by "id" asc limit 17', + $builder->toSql()); + $this->assertEquals([2], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['id'], + ]), $result); + } + + public function testCursorPaginateWithMixedOrders() + { + $perPage = 16; + $columns = ['foo', 'bar', 'baz']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['foo' => 1, 'bar' => 2, 'baz' => 3]); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('foo')->orderByDesc('bar')->orderBy('baz'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['foo' => 1, 'bar' => 2, 'baz' => 4], ['foo' => 1, 'bar' => 1, 'baz' => 1]]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("foo" > ? or ("foo" = ? and ("bar" < ? or ("bar" = ? and ("baz" > ?))))) order by "foo" asc, "bar" desc, "baz" asc limit 17', + $builder->toSql() + ); + $this->assertEquals([1, 1, 2, 2, 3], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['foo', 'bar', 'baz'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheres() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" > ?)) union (select "id", "created_at", \'news\' as type from "news" where ("start_time" > ?)) order by "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals([$ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresWithRawOrderExpression() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'is_published', 'start_time as created_at')->selectRaw("'video' as type")->where('is_published', true)->from('videos'); + $builder->union($this->getBuilder()->select('id', 'is_published', 'created_at')->selectRaw("'news' as type")->where('is_published', true)->from('news')); + $builder->orderByRaw('case when (id = 3 and type="news" then 0 else 1 end)')->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video', 'is_published' => true], + ['id' => 2, 'created_at' => now(), 'type' => 'news', 'is_published' => true], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "is_published", "start_time" as "created_at", \'video\' as type from "videos" where "is_published" = ? and ("start_time" > ?)) union (select "id", "is_published", "created_at", \'news\' as type from "news" where "is_published" = ? and ("start_time" > ?)) order by case when (id = 3 and type="news" then 0 else 1 end), "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([true, $ts], $builder->bindings['where']); + $this->assertEquals([true, $ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresReverseOrder() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts], false); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" < ?)) union (select "id", "created_at", \'news\' as type from "news" where ("start_time" < ?)) order by "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals([$ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresMultipleOrders() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts, 'id' => 1]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderByDesc('created_at')->orderBy('id'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" < ? or ("start_time" = ? and ("id" > ?)))) union (select "id", "created_at", \'news\' as type from "news" where ("start_time" < ? or ("start_time" = ? and ("id" > ?)))) order by "created_at" desc, "id" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts, $ts, 1], $builder->bindings['where']); + $this->assertEquals([$ts, $ts, 1], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at', 'id'], + ]), $result); + } + public function testWhereRowValues() { $builder = $this->getBuilder(); @@ -3607,12 +4497,69 @@ SQL; $this->assertEquals(['1520652582'], $builder->getBindings()); } + public function testFromQuestionMarkOperatorOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?', 'superuser'); + $this->assertSame('select * from "users" where "roles" ?? ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?|', 'superuser'); + $this->assertSame('select * from "users" where "roles" ??| ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?&', 'superuser'); + $this->assertSame('select * from "users" where "roles" ??& ?', $builder->toSql()); + } + + public function testClone() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users'); + $clone = $builder->clone()->where('email', 'foo'); + + $this->assertNotSame($builder, $clone); + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + + public function testCloneWithout() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', 'foo')->orderBy('email'); + $clone = $builder->cloneWithout(['orders']); + + $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + + public function testCloneWithoutBindings() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', 'foo')->orderBy('email'); + $clone = $builder->cloneWithout(['wheres'])->cloneWithoutBindings(['where']); + + $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $builder->toSql()); + $this->assertEquals([0 => 'foo'], $builder->getBindings()); + + $this->assertSame('select * from "users" order by "email" asc', $clone->toSql()); + $this->assertEquals([], $clone->getBindings()); + } + + protected function getConnection() + { + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + + return $connection; + } + protected function getBuilder() { $grammar = new Grammar; $processor = m::mock(Processor::class); - return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor); + return new Builder($this->getConnection(), $grammar, $processor); } protected function getPostgresBuilder() @@ -3620,7 +4567,7 @@ SQL; $grammar = new PostgresGrammar; $processor = m::mock(Processor::class); - return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor); + return new Builder($this->getConnection(), $grammar, $processor); } protected function getMySqlBuilder() @@ -3644,7 +4591,7 @@ SQL; $grammar = new SqlServerGrammar; $processor = m::mock(Processor::class); - return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor); + return new Builder($this->getConnection(), $grammar, $processor); } protected function getMySqlBuilderWithProcessor() @@ -3655,8 +4602,16 @@ SQL; return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor); } + protected function getPostgresBuilderWithProcessor() + { + $grammar = new PostgresGrammar; + $processor = new PostgresProcessor; + + return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor); + } + /** - * @return m\MockInterface + * @return \Mockery\MockInterface|\Illuminate\Database\Query\Builder */ protected function getMockQueryBuilder() { diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4b4ab452dc35d6f9d68c7b902a43d9ea74f2929c --- /dev/null +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -0,0 +1,91 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Container\Container; +use Illuminate\Database\Connection; +use Illuminate\Database\Schema\SQLiteBuilder; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Facade; +use Illuminate\Support\Facades\File; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class DatabaseSQLiteBuilderTest extends TestCase +{ + protected function setUp(): void + { + $app = new Container; + + Container::setInstance($app) + ->singleton('files', Filesystem::class); + + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + m::close(); + + Container::setInstance(null); + Facade::setFacadeApplication(null); + } + + public function testCreateDatabase() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + File::shouldReceive('put') + ->once() + ->with('my_temporary_database_a', '') + ->andReturn(20); // bytes + + $this->assertTrue($builder->createDatabase('my_temporary_database_a')); + + File::shouldReceive('put') + ->once() + ->with('my_temporary_database_b', '') + ->andReturn(false); + + $this->assertFalse($builder->createDatabase('my_temporary_database_b')); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + File::shouldReceive('exists') + ->once() + ->andReturn(true); + + File::shouldReceive('delete') + ->once() + ->with('my_temporary_database_b') + ->andReturn(true); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_b')); + + File::shouldReceive('exists') + ->once() + ->andReturn(false); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_c')); + + File::shouldReceive('exists') + ->once() + ->andReturn(true); + + File::shouldReceive('delete') + ->once() + ->with('my_temporary_database_c') + ->andReturn(false); + + $this->assertFalse($builder->dropDatabaseIfExists('my_temporary_database_c')); + } +} diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index b2481687395f630491ba08e31304899c3aec111d..4d1dbfa83ccf9a3961756892174823d93f080d35 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -2,10 +2,10 @@ namespace Illuminate\Tests\Database; -use Doctrine\DBAL\Schema\SqliteSchemaManager; use Illuminate\Database\Capsule\Manager; use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ForeignIdColumnDefinition; use Illuminate\Database\Schema\Grammars\SQLiteGrammar; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -97,10 +97,6 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase public function testDropColumn() { - if (! class_exists(SqliteSchemaManager::class)) { - $this->markTestSkipped('Doctrine should be installed to run dropColumn tests'); - } - $db = new Manager; $db->addConnection([ @@ -148,10 +144,6 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase public function testRenameIndex() { - if (! class_exists(SqliteSchemaManager::class)) { - $this->markTestSkipped('Doctrine should be installed to run renameIndex tests'); - } - $db = new Manager; $db->addConnection([ @@ -251,6 +243,16 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $blueprint->toSql($this->getConnection(), $this->getGrammar()); } + public function testAddingRawIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + public function testAddingIncrementingID() { $blueprint = new Blueprint('users'); @@ -281,6 +283,44 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add column "id" integer not null primary key autoincrement', $statements[0]); } + public function testAddingID() + { + $blueprint = new Blueprint('users'); + $blueprint->id(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer not null primary key autoincrement', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null primary key autoincrement', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint('users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add column "foo" integer not null', + 'alter table "users" add column "company_id" integer not null', + 'alter table "users" add column "laravel_idea_id" integer not null', + 'alter table "users" add column "team_id" integer not null', + 'alter table "users" add column "team_column_id" integer not null', + ], $statements); + } + public function testAddingBigIncrementingID() { $blueprint = new Blueprint('users'); @@ -312,7 +352,7 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(1, $statements); - $this->assertSame('alter table "users" add column "foo" varchar null default \'bar\'', $statements[0]); + $this->assertSame('alter table "users" add column "foo" varchar default \'bar\'', $statements[0]); } public function testAddingText() @@ -614,8 +654,8 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(2, $statements); $this->assertEquals([ - 'alter table "users" add column "created_at" datetime null', - 'alter table "users" add column "updated_at" datetime null', + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', ], $statements); } @@ -626,8 +666,8 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(2, $statements); $this->assertEquals([ - 'alter table "users" add column "created_at" datetime null', - 'alter table "users" add column "updated_at" datetime null', + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', ], $statements); } @@ -638,7 +678,7 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(1, $statements); - $this->assertSame('alter table "users" add column "remember_token" varchar null', $statements[0]); + $this->assertSame('alter table "users" add column "remember_token" varchar', $statements[0]); } public function testAddingBinary() @@ -661,6 +701,27 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); } + public function testAddingForeignUuid() + { + $blueprint = new Blueprint('users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table "users" add column "foo" varchar not null', + 'alter table "users" add column "company_id" varchar not null', + 'alter table "users" add column "laravel_idea_id" varchar not null', + 'alter table "users" add column "team_id" varchar not null', + 'alter table "users" add column "team_column_id" varchar not null', + ], $statements); + } + public function testAddingIpAddress() { $blueprint = new Blueprint('users'); @@ -761,6 +822,32 @@ class DatabaseSQLiteSchemaGrammarTest extends TestCase $this->assertSame('alter table "geo" add column "coordinates" multipolygon not null', $statements[0]); } + public function testAddingGeneratedColumn() + { + $blueprint = new Blueprint('products'); + $blueprint->create(); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5'); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "products" ("price" integer not null, "discounted_virtual" integer as ("price" - 5), "discounted_stored" integer as ("price" - 5) stored)', $statements[0]); + + $blueprint = new Blueprint('products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5')->nullable(false); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5')->nullable(false); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $expected = [ + 'alter table "products" add column "price" integer not null', + 'alter table "products" add column "discounted_virtual" integer as ("price" - 5) not null', + ]; + $this->assertSame($expected, $statements); + } + public function testGrammarsAreMacroable() { // compileReplace macro. diff --git a/tests/Database/DatabaseSchemaBlueprintIntegrationTest.php b/tests/Database/DatabaseSchemaBlueprintIntegrationTest.php index 3fb7300a7b5bae95953a767fc328032f49381692..2ee553a14d5019127d5363f2613f1532de0d8381 100644 --- a/tests/Database/DatabaseSchemaBlueprintIntegrationTest.php +++ b/tests/Database/DatabaseSchemaBlueprintIntegrationTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Database; +use BadMethodCallException; use Illuminate\Container\Container; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Schema\Blueprint; @@ -57,20 +58,35 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $queries = $blueprint->toSql($this->db->connection(), new SQLiteGrammar); + // Expect one of the following two query sequences to be present... $expected = [ - 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', - 'DROP TABLE users', - 'CREATE TABLE users (name VARCHAR(255) NOT NULL COLLATE BINARY, age INTEGER NOT NULL)', - 'INSERT INTO users (name, age) SELECT name, age FROM __temp__users', - 'DROP TABLE __temp__users', - 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', - 'DROP TABLE users', - 'CREATE TABLE users (age VARCHAR(255) NOT NULL COLLATE BINARY, first_name VARCHAR(255) NOT NULL)', - 'INSERT INTO users (first_name, age) SELECT name, age FROM __temp__users', - 'DROP TABLE __temp__users', + [ + 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', + 'DROP TABLE users', + 'CREATE TABLE users (name VARCHAR(255) NOT NULL COLLATE BINARY, age INTEGER NOT NULL)', + 'INSERT INTO users (name, age) SELECT name, age FROM __temp__users', + 'DROP TABLE __temp__users', + 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', + 'DROP TABLE users', + 'CREATE TABLE users (age VARCHAR(255) NOT NULL COLLATE BINARY, first_name VARCHAR(255) NOT NULL)', + 'INSERT INTO users (first_name, age) SELECT name, age FROM __temp__users', + 'DROP TABLE __temp__users', + ], + [ + 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', + 'DROP TABLE users', + 'CREATE TABLE users (name VARCHAR(255) NOT NULL COLLATE BINARY, age INTEGER NOT NULL)', + 'INSERT INTO users (name, age) SELECT name, age FROM __temp__users', + 'DROP TABLE __temp__users', + 'CREATE TEMPORARY TABLE __temp__users AS SELECT name, age FROM users', + 'DROP TABLE users', + 'CREATE TABLE users (first_name VARCHAR(255) NOT NULL, age VARCHAR(255) NOT NULL COLLATE BINARY)', + 'INSERT INTO users (first_name, age) SELECT name, age FROM __temp__users', + 'DROP TABLE __temp__users', + ], ]; - $this->assertEquals($expected, $queries); + $this->assertTrue(in_array($queries, $expected)); } public function testChangingColumnWithCollationWorks() @@ -169,7 +185,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->string('name')->nullable()->unique()->change(); }); - $queries = $blueprintMySql->toSql($this->db->connection(), new MySqlGrammar()); + $queries = $blueprintMySql->toSql($this->db->connection(), new MySqlGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -186,7 +202,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->string('name')->nullable()->unique()->change(); }); - $queries = $blueprintPostgres->toSql($this->db->connection(), new PostgresGrammar()); + $queries = $blueprintPostgres->toSql($this->db->connection(), new PostgresGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -203,7 +219,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->string('name')->nullable()->unique()->change(); }); - $queries = $blueprintSQLite->toSql($this->db->connection(), new SQLiteGrammar()); + $queries = $blueprintSQLite->toSql($this->db->connection(), new SQLiteGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -220,7 +236,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->string('name')->nullable()->unique()->change(); }); - $queries = $blueprintSqlServer->toSql($this->db->connection(), new SqlServerGrammar()); + $queries = $blueprintSqlServer->toSql($this->db->connection(), new SqlServerGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -244,7 +260,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->string('name')->nullable()->unique('index1')->change(); }); - $queries = $blueprintMySql->toSql($this->db->connection(), new MySqlGrammar()); + $queries = $blueprintMySql->toSql($this->db->connection(), new MySqlGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -261,7 +277,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->unsignedInteger('name')->nullable()->unique('index1')->change(); }); - $queries = $blueprintPostgres->toSql($this->db->connection(), new PostgresGrammar()); + $queries = $blueprintPostgres->toSql($this->db->connection(), new PostgresGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -278,7 +294,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->unsignedInteger('name')->nullable()->unique('index1')->change(); }); - $queries = $blueprintSQLite->toSql($this->db->connection(), new SQLiteGrammar()); + $queries = $blueprintSQLite->toSql($this->db->connection(), new SQLiteGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -295,7 +311,7 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $table->unsignedInteger('name')->nullable()->unique('index1')->change(); }); - $queries = $blueprintSqlServer->toSql($this->db->connection(), new SqlServerGrammar()); + $queries = $blueprintSqlServer->toSql($this->db->connection(), new SqlServerGrammar); $expected = [ 'CREATE TEMPORARY TABLE __temp__users AS SELECT name FROM users', @@ -308,4 +324,47 @@ class DatabaseSchemaBlueprintIntegrationTest extends TestCase $this->assertEquals($expected, $queries); } + + public function testItEnsuresDroppingMultipleColumnsIsAvailable() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage("SQLite doesn't support multiple calls to dropColumn / renameColumn in a single modification."); + + $this->db->connection()->getSchemaBuilder()->table('users', function (Blueprint $table) { + $table->dropColumn('name'); + $table->dropColumn('email'); + }); + } + + public function testItEnsuresRenamingMultipleColumnsIsAvailable() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage("SQLite doesn't support multiple calls to dropColumn / renameColumn in a single modification."); + + $this->db->connection()->getSchemaBuilder()->table('users', function (Blueprint $table) { + $table->renameColumn('name', 'first_name'); + $table->renameColumn('name2', 'last_name'); + }); + } + + public function testItEnsuresRenamingAndDroppingMultipleColumnsIsAvailable() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage("SQLite doesn't support multiple calls to dropColumn / renameColumn in a single modification."); + + $this->db->connection()->getSchemaBuilder()->table('users', function (Blueprint $table) { + $table->dropColumn('name'); + $table->renameColumn('name2', 'last_name'); + }); + } + + public function testItEnsuresDroppingForeignKeyIsAvailable() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage("SQLite doesn't support dropping foreign keys (you would need to re-create the table)."); + + $this->db->connection()->getSchemaBuilder()->table('users', function (Blueprint $table) { + $table->dropForeign('something'); + }); + } } diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index 10f8ad7579703ae2a0bee7c187f03f557a7a2b14..5247df35c3ba253bd63a81f88bada17f2b3c696a 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\Builder; use Illuminate\Database\Schema\Grammars\MySqlGrammar; use Illuminate\Database\Schema\Grammars\PostgresGrammar; use Illuminate\Database\Schema\Grammars\SQLiteGrammar; @@ -16,6 +17,7 @@ class DatabaseSchemaBlueprintTest extends TestCase protected function tearDown(): void { m::close(); + Builder::$defaultMorphKeyType = 'int'; } public function testToSqlRunsCommandsFromBlueprint() @@ -24,7 +26,7 @@ class DatabaseSchemaBlueprintTest extends TestCase $conn->shouldReceive('statement')->once()->with('foo'); $conn->shouldReceive('statement')->once()->with('bar'); $grammar = m::mock(MySqlGrammar::class); - $blueprint = $this->getMockBuilder(Blueprint::class)->setMethods(['toSql'])->setConstructorArgs(['users'])->getMock(); + $blueprint = $this->getMockBuilder(Blueprint::class)->onlyMethods(['toSql'])->setConstructorArgs(['users'])->getMock(); $blueprint->expects($this->once())->method('toSql')->with($this->equalTo($conn), $this->equalTo($grammar))->willReturn(['foo', 'bar']); $blueprint->build($conn, $grammar); @@ -189,4 +191,162 @@ class DatabaseSchemaBlueprintTest extends TestCase $this->assertEquals(['bar'], $blueprint->toSql($connection, new MySqlGrammar)); } + + public function testDefaultUsingIdMorph() + { + $base = new Blueprint('comments', function ($table) { + $table->morphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null, add `commentable_id` bigint unsigned not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testDefaultUsingNullableIdMorph() + { + $base = new Blueprint('comments', function ($table) { + $table->nullableMorphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null, add `commentable_id` bigint unsigned null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testDefaultUsingUuidMorph() + { + Builder::defaultMorphKeyType('uuid'); + + $base = new Blueprint('comments', function ($table) { + $table->morphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null, add `commentable_id` char(36) not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testDefaultUsingNullableUuidMorph() + { + Builder::defaultMorphKeyType('uuid'); + + $base = new Blueprint('comments', function ($table) { + $table->nullableMorphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null, add `commentable_id` char(36) null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testGenerateRelationshipColumnWithIncrementalModel() + { + $base = new Blueprint('posts', function ($table) { + $table->foreignIdFor('Illuminate\Foundation\Auth\User'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `posts` add `user_id` bigint unsigned not null', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testGenerateRelationshipColumnWithUuidModel() + { + require_once __DIR__.'/stubs/EloquentModelUuidStub.php'; + + $base = new Blueprint('posts', function ($table) { + $table->foreignIdFor('EloquentModelUuidStub'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `posts` add `eloquent_model_uuid_stub_id` char(36) not null', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testTinyTextColumn() + { + $base = new Blueprint('posts', function ($table) { + $table->tinyText('note'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table `posts` add `note` tinytext not null', + ], $blueprint->toSql($connection, new MySqlGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add column "note" text not null', + ], $blueprint->toSql($connection, new SQLiteGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add column "note" varchar(255) not null', + ], $blueprint->toSql($connection, new PostgresGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add "note" nvarchar(255) not null', + ], $blueprint->toSql($connection, new SqlServerGrammar)); + } + + public function testTinyTextNullableColumn() + { + $base = new Blueprint('posts', function ($table) { + $table->tinyText('note')->nullable(); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table `posts` add `note` tinytext null', + ], $blueprint->toSql($connection, new MySqlGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add column "note" text', + ], $blueprint->toSql($connection, new SQLiteGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add column "note" varchar(255) null', + ], $blueprint->toSql($connection, new PostgresGrammar)); + + $blueprint = clone $base; + $this->assertEquals([ + 'alter table "posts" add "note" nvarchar(255) null', + ], $blueprint->toSql($connection, new SqlServerGrammar)); + } } diff --git a/tests/Database/DatabaseSchemaBuilderIntegrationTest.php b/tests/Database/DatabaseSchemaBuilderIntegrationTest.php index 305b0cd28545112b5cab0c10ea78a880c9d9c40f..d469645fc8a1e7abbdde5adc9cbf4cdc1ab293d9 100644 --- a/tests/Database/DatabaseSchemaBuilderIntegrationTest.php +++ b/tests/Database/DatabaseSchemaBuilderIntegrationTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Container\Container; use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\TestCase; @@ -40,12 +41,12 @@ class DatabaseSchemaBuilderIntegrationTest extends TestCase public function testDropAllTablesWorksWithForeignKeys() { - $this->db->connection()->getSchemaBuilder()->create('table1', function ($table) { + $this->db->connection()->getSchemaBuilder()->create('table1', function (Blueprint $table) { $table->integer('id'); $table->string('name'); }); - $this->db->connection()->getSchemaBuilder()->create('table2', function ($table) { + $this->db->connection()->getSchemaBuilder()->create('table2', function (Blueprint $table) { $table->integer('id'); $table->string('user_id'); $table->foreign('user_id')->references('id')->on('table1'); @@ -64,7 +65,7 @@ class DatabaseSchemaBuilderIntegrationTest extends TestCase { $this->db->connection()->setTablePrefix('test_'); - $this->db->connection()->getSchemaBuilder()->create('table1', function ($table) { + $this->db->connection()->getSchemaBuilder()->create('table1', function (Blueprint $table) { $table->integer('id'); $table->string('name'); }); @@ -81,7 +82,7 @@ class DatabaseSchemaBuilderIntegrationTest extends TestCase 'prefix_indexes' => false, ]); - $this->db->connection()->getSchemaBuilder()->create('table1', function ($table) { + $this->db->connection()->getSchemaBuilder()->create('table1', function (Blueprint $table) { $table->integer('id'); $table->string('name')->index(); }); @@ -98,11 +99,39 @@ class DatabaseSchemaBuilderIntegrationTest extends TestCase 'prefix_indexes' => true, ]); - $this->db->connection()->getSchemaBuilder()->create('table1', function ($table) { + $this->db->connection()->getSchemaBuilder()->create('table1', function (Blueprint $table) { $table->integer('id'); $table->string('name')->index(); }); $this->assertArrayHasKey('example_table1_name_index', $this->db->connection()->getDoctrineSchemaManager()->listTableIndexes('example_table1')); } + + public function testDropColumnWithTablePrefix() + { + $this->db->connection()->setTablePrefix('test_'); + + $this->schemaBuilder()->create('pandemic_table', function (Blueprint $table) { + $table->integer('id'); + $table->string('stay_home'); + $table->string('covid19'); + $table->string('wear_mask'); + }); + + // drop single columns + $this->assertTrue($this->schemaBuilder()->hasColumn('pandemic_table', 'stay_home')); + $this->schemaBuilder()->dropColumns('pandemic_table', 'stay_home'); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'stay_home')); + + // drop multiple columns + $this->assertTrue($this->schemaBuilder()->hasColumn('pandemic_table', 'covid19')); + $this->schemaBuilder()->dropColumns('pandemic_table', ['covid19', 'wear_mask']); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'wear_mask')); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'covid19')); + } + + private function schemaBuilder() + { + return $this->db->connection()->getSchemaBuilder(); + } } diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index a6550708a6a8f2cb2bb1c42efa5b4152a5975619..b22bfd7dc70ec98e947faa9b64ee665f83f9c47c 100755 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; use Illuminate\Database\Schema\Builder; +use LogicException; use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; @@ -15,6 +16,32 @@ class DatabaseSchemaBuilderTest extends TestCase m::close(); } + public function testCreateDatabase() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(stdClass::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = new Builder($connection); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('This database driver does not support creating databases.'); + + $builder->createDatabase('foo'); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(stdClass::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = new Builder($connection); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('This database driver does not support dropping databases.'); + + $builder->dropDatabaseIfExists('foo'); + } + public function testHasTableCorrectlyCallsGrammar() { $connection = m::mock(Connection::class); @@ -53,6 +80,6 @@ class DatabaseSchemaBuilderTest extends TestCase $column->shouldReceive('getType')->once()->andReturn($type); $type->shouldReceive('getName')->once()->andReturn('integer'); - $this->assertEquals($builder->getColumnType('users', 'id'), 'integer'); + $this->assertSame('integer', $builder->getColumnType('users', 'id')); } } diff --git a/tests/Database/DatabaseSeederTest.php b/tests/Database/DatabaseSeederTest.php index da2c39bcee89575b004e07b38b7907749a4f6b3f..7926a1ff2a2791f9bb68a8132fdebd560c2bfede 100755 --- a/tests/Database/DatabaseSeederTest.php +++ b/tests/Database/DatabaseSeederTest.php @@ -20,7 +20,7 @@ class TestSeeder extends Seeder class TestDepsSeeder extends Seeder { - public function run(Mock $someDependency) + public function run(Mock $someDependency, $someParam = '') { // } @@ -76,6 +76,19 @@ class DatabaseSeederTest extends TestCase $seeder->__invoke(); - $container->shouldHaveReceived('call')->once()->with([$seeder, 'run']); + $container->shouldHaveReceived('call')->once()->with([$seeder, 'run'], []); + } + + public function testSendParamsOnCallMethodWithDeps() + { + $container = m::mock(Container::class); + $container->shouldReceive('call'); + + $seeder = new TestDepsSeeder; + $seeder->setContainer($container); + + $seeder->__invoke(['test1', 'test2']); + + $container->shouldHaveReceived('call')->once()->with([$seeder, 'run'], ['test1', 'test2']); } } diff --git a/tests/Database/DatabaseSoftDeletingTest.php b/tests/Database/DatabaseSoftDeletingTest.php index f14bd9f90cd12e72a9e2b4b9913a6e26b564492f..3b136b92670fd2acb48c31d7c84d7d63c0544bbb 100644 --- a/tests/Database/DatabaseSoftDeletingTest.php +++ b/tests/Database/DatabaseSoftDeletingTest.php @@ -9,23 +9,12 @@ use PHPUnit\Framework\TestCase; class DatabaseSoftDeletingTest extends TestCase { - public function testDeletedAtIsAddedToDateCasts() + public function testDeletedAtIsAddedToCastsAsDefaultType() { $model = new SoftDeletingModel; - $this->assertContains('deleted_at', $model->getDates()); - } - - public function testDeletedAtIsUniqueWhenAlreadyExists() - { - $model = new class extends SoftDeletingModel { - protected $dates = ['deleted_at']; - }; - $entries = array_filter($model->getDates(), function ($attribute) { - return $attribute === 'deleted_at'; - }); - - $this->assertCount(1, $entries); + $this->assertArrayHasKey('deleted_at', $model->getCasts()); + $this->assertSame('datetime', $model->getCasts()['deleted_at']); } public function testDeletedAtIsCastToCarbonInstance() @@ -40,7 +29,8 @@ class DatabaseSoftDeletingTest extends TestCase public function testExistingCastOverridesAddedDateCast() { - $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { protected $casts = ['deleted_at' => 'bool']; }; @@ -49,7 +39,8 @@ class DatabaseSoftDeletingTest extends TestCase public function testExistingMutatorOverridesAddedDateCast() { - $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { protected function getDeletedAtAttribute() { return 'expected'; @@ -61,7 +52,8 @@ class DatabaseSoftDeletingTest extends TestCase public function testCastingToStringOverridesAutomaticDateCastingToRetainPreviousBehaviour() { - $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { protected $casts = ['deleted_at' => 'string']; }; diff --git a/tests/Database/DatabaseSoftDeletingTraitTest.php b/tests/Database/DatabaseSoftDeletingTraitTest.php index 7db55979cd3cc9593cffa3b2afb0685c75a6931f..39c2e15153dcaedab5e11b63be28f78385c8b48c 100644 --- a/tests/Database/DatabaseSoftDeletingTraitTest.php +++ b/tests/Database/DatabaseSoftDeletingTraitTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class DatabaseSoftDeletingTraitTest extends TestCase { diff --git a/tests/Database/DatabaseSqlServerSchemaGrammarTest.php b/tests/Database/DatabaseSqlServerSchemaGrammarTest.php index c157cbc6ae492d7de6f78a9a0494029e83248f8d..ef064062ad3b9dfad161c1df96154bb7661a5c41 100755 --- a/tests/Database/DatabaseSqlServerSchemaGrammarTest.php +++ b/tests/Database/DatabaseSqlServerSchemaGrammarTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ForeignIdColumnDefinition; use Illuminate\Database\Schema\Grammars\SqlServerGrammar; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -81,14 +82,14 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(1, $statements); - $this->assertSame('if exists (select * from INFORMATION_SCHEMA.TABLES where TABLE_NAME = \'users\') drop table "users"', $statements[0]); + $this->assertSame('if exists (select * from sys.sysobjects where id = object_id(\'users\', \'U\')) drop table "users"', $statements[0]); $blueprint = new Blueprint('users'); $blueprint->dropIfExists(); $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()->setTablePrefix('prefix_')); $this->assertCount(1, $statements); - $this->assertSame('if exists (select * from INFORMATION_SCHEMA.TABLES where TABLE_NAME = \'prefix_users\') drop table "prefix_users"', $statements[0]); + $this->assertSame('if exists (select * from sys.sysobjects where id = object_id(\'prefix_users\', \'U\')) drop table "prefix_users"', $statements[0]); } public function testDropColumn() @@ -122,7 +123,7 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(1, $statements); - $this->assertEquals("DECLARE @sql NVARCHAR(MAX) = '';SELECT @sql += 'ALTER TABLE [dbo].[foo] DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' FROM SYS.COLUMNS WHERE [object_id] = OBJECT_ID('[dbo].[foo]') AND [name] in ('bar') AND [default_object_id] <> 0;EXEC(@sql);alter table \"foo\" drop column \"bar\"", $statements[0]); + $this->assertSame("DECLARE @sql NVARCHAR(MAX) = '';SELECT @sql += 'ALTER TABLE [dbo].[foo] DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' FROM sys.columns WHERE [object_id] = OBJECT_ID('[dbo].[foo]') AND [name] in ('bar') AND [default_object_id] <> 0;EXEC(@sql);alter table \"foo\" drop column \"bar\"", $statements[0]); } public function testDropPrimary() @@ -175,6 +176,17 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" drop constraint "foo"', $statements[0]); } + public function testDropConstrainedForeignId() + { + $blueprint = new Blueprint('users'); + $blueprint->dropConstrainedForeignId('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" drop constraint "users_foo_foreign"', $statements[0]); + $this->assertSame('DECLARE @sql NVARCHAR(MAX) = \'\';SELECT @sql += \'ALTER TABLE [dbo].[users] DROP CONSTRAINT \' + OBJECT_NAME([default_object_id]) + \';\' FROM sys.columns WHERE [object_id] = OBJECT_ID(\'[dbo].[users]\') AND [name] in (\'foo\') AND [default_object_id] <> 0;EXEC(@sql);alter table "users" drop column "foo"', $statements[1]); + } + public function testDropTimestamps() { $blueprint = new Blueprint('users'); @@ -202,7 +214,7 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); $this->assertCount(2, $statements); - $this->assertEquals('drop index "photos_imageable_type_imageable_id_index" on "photos"', $statements[0]); + $this->assertSame('drop index "photos_imageable_type_imageable_id_index" on "photos"', $statements[0]); $this->assertStringContainsString('alter table "photos" drop column "imageable_type", "imageable_id"', $statements[1]); } @@ -276,6 +288,16 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $this->assertSame('create spatial index "geo_coordinates_spatialindex" on "geo" ("coordinates")', $statements[1]); } + public function testAddingRawIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + public function testAddingIncrementingID() { $blueprint = new Blueprint('users'); @@ -306,6 +328,44 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add "id" int identity primary key not null', $statements[0]); } + public function testAddingID() + { + $blueprint = new Blueprint('users'); + $blueprint->id(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add "id" bigint identity primary key not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add "foo" bigint identity primary key not null', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint('users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add "foo" bigint not null, "company_id" bigint not null, "laravel_idea_id" bigint not null, "team_id" bigint not null, "team_column_id" bigint not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + public function testAddingBigIncrementingID() { $blueprint = new Blueprint('users'); @@ -680,6 +740,27 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $this->assertSame('alter table "users" add "foo" uniqueidentifier not null', $statements[0]); } + public function testAddingForeignUuid() + { + $blueprint = new Blueprint('users'); + $foreignId = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add "foo" uniqueidentifier not null, "company_id" uniqueidentifier not null, "laravel_idea_id" uniqueidentifier not null, "team_id" uniqueidentifier not null, "team_column_id" uniqueidentifier not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + public function testAddingIpAddress() { $blueprint = new Blueprint('users'); @@ -813,6 +894,42 @@ class DatabaseSqlServerSchemaGrammarTest extends TestCase $this->assertSame("N'中文', N'測試'", $this->getGrammar()->quoteString(['中文', '測試'])); } + public function testCreateDatabase() + { + $connection = $this->getConnection(); + + $statement = $this->getGrammar()->compileCreateDatabase('my_database_a', $connection); + + $this->assertSame( + 'create database "my_database_a"', + $statement + ); + + $statement = $this->getGrammar()->compileCreateDatabase('my_database_b', $connection); + + $this->assertSame( + 'create database "my_database_b"', + $statement + ); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists "my_database_a"', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists "my_database_b"', + $statement + ); + } + protected function getConnection() { return m::mock(Connection::class); diff --git a/tests/Database/DatabaseTransactionsManagerTest.php b/tests/Database/DatabaseTransactionsManagerTest.php new file mode 100755 index 0000000000000000000000000000000000000000..e8d82e048720e1e138491e0afdcc97f1f501b493 --- /dev/null +++ b/tests/Database/DatabaseTransactionsManagerTest.php @@ -0,0 +1,166 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\DatabaseTransactionsManager; +use PHPUnit\Framework\TestCase; + +class DatabaseTransactionsManagerTest extends TestCase +{ + public function testBeginningTransactions() + { + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $this->assertCount(3, $manager->getTransactions()); + $this->assertSame('default', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + $this->assertSame('default', $manager->getTransactions()[1]->connection); + $this->assertEquals(2, $manager->getTransactions()[1]->level); + $this->assertSame('admin', $manager->getTransactions()[2]->connection); + $this->assertEquals(1, $manager->getTransactions()[2]->level); + } + + public function testRollingBackTransactions() + { + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 1); + + $this->assertCount(2, $manager->getTransactions()); + + $this->assertSame('default', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + + $this->assertSame('admin', $manager->getTransactions()[1]->connection); + $this->assertEquals(1, $manager->getTransactions()[1]->level); + } + + public function testRollingBackTransactionsAllTheWay() + { + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 0); + + $this->assertCount(1, $manager->getTransactions()); + + $this->assertSame('admin', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + } + + public function testCommittingTransactions() + { + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->commit('default'); + + $this->assertCount(1, $manager->getTransactions()); + + $this->assertSame('admin', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + } + + public function testCallbacksAreAddedToTheCurrentTransaction() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $manager->begin('default', 2); + + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $this->assertCount(1, $manager->getTransactions()[0]->getCallbacks()); + $this->assertCount(0, $manager->getTransactions()[1]->getCallbacks()); + $this->assertCount(1, $manager->getTransactions()[2]->getCallbacks()); + } + + public function testCommittingTransactionsExecutesCallbacks() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 2]; + }); + + $manager->begin('admin', 1); + + $manager->commit('default'); + + $this->assertCount(2, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + $this->assertEquals(['default', 2], $callbacks[1]); + } + + public function testCommittingExecutesOnlyCallbacksOfTheConnection() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['admin', 1]; + }); + + $manager->commit('default'); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } + + public function testCallbackIsExecutedIfNoTransactions() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } +} diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..167dbaee6a48abad4e6794f79952ce5ac9b41061 --- /dev/null +++ b/tests/Database/DatabaseTransactionsTest.php @@ -0,0 +1,256 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Exception; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\DatabaseTransactionsManager; +use Mockery as m; +use PHPUnit\Framework\TestCase; +use Throwable; + +class DatabaseTransactionsTest extends TestCase +{ + /** + * Setup the database schema. + * + * @return void + */ + protected function setUp(): void + { + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'second_connection'); + + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->create('users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('value')->nullable(); + }); + } + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->drop('users'); + } + + m::close(); + } + + public function testTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + } + + public function testTransactionIsRecordedAndCommittedUsingTheSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + $this->connection()->commit(); + } + + public function testNestedTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + } + + public function testNestedTransactionIsRecordeForDifferentConnectionsdAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 2); + $transactionManager->shouldReceive('commit')->once()->with('default'); + $transactionManager->shouldReceive('commit')->once()->with('second_connection'); + + $this->connection()->setTransactionManager($transactionManager); + $this->connection('second_connection')->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + }); + } + + public function testTransactionIsRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new Exception; + }); + } catch (Throwable $e) { + } + } + + public function testTransactionIsRolledBackUsingSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->rollBack(); + } + + public function testNestedTransactionsAreRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('rollback')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new Exception; + }); + }); + } catch (Throwable $e) { + } + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } + + public function connection($name = 'default') + { + return DB::connection($name); + } +} diff --git a/tests/Database/Fixtures/Factories/Money/PriceFactory.php b/tests/Database/Fixtures/Factories/Money/PriceFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..bf49e8be2176192d48c8b5884bd8fb388cb74eb6 --- /dev/null +++ b/tests/Database/Fixtures/Factories/Money/PriceFactory.php @@ -0,0 +1,15 @@ +<?php + +namespace Illuminate\Tests\Database\Fixtures\Factories\Money; + +use Illuminate\Database\Eloquent\Factories\Factory; + +class PriceFactory extends Factory +{ + public function definition() + { + return [ + 'name' => $this->faker->name, + ]; + } +} diff --git a/tests/Database/Fixtures/Models/Money/Price.php b/tests/Database/Fixtures/Models/Money/Price.php new file mode 100644 index 0000000000000000000000000000000000000000..7fd74460736dc78b6375e019f5bf5919a8043aff --- /dev/null +++ b/tests/Database/Fixtures/Models/Money/Price.php @@ -0,0 +1,19 @@ +<?php + +namespace Illuminate\Tests\Database\Fixtures\Models\Money; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Tests\Database\Fixtures\Factories\Money\PriceFactory; + +class Price extends Model +{ + use HasFactory; + + protected $table = 'prices'; + + public static function factory() + { + return PriceFactory::new(); + } +} diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..19246def7ed32718ff5cd0b809ef1c407a5261f2 --- /dev/null +++ b/tests/Database/PruneCommandTest.php @@ -0,0 +1,244 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Events\Dispatcher as DispatcherContract; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Console\PruneCommand; +use Illuminate\Database\Eloquent\MassPrunable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Prunable; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Events\ModelsPruned; +use Illuminate\Events\Dispatcher; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class PruneCommandTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Container::setInstance($container = new Container); + + $container->singleton(DispatcherContract::class, function () { + return new Dispatcher(); + }); + + $container->alias(DispatcherContract::class, 'events'); + } + + public function testPrunableModelWithPrunableRecords() + { + $output = $this->artisan(['--model' => PrunableTestModelWithPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +10 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. +20 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. + +EOF, str_replace("\r", '', $output->fetch())); + } + + public function testPrunableTestModelWithoutPrunableRecords() + { + $output = $this->artisan(['--model' => PrunableTestModelWithoutPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\PrunableTestModelWithoutPrunableRecords] records found. + +EOF, str_replace("\r", '', $output->fetch())); + } + + public function testPrunableSoftDeletedModelWithPrunableRecords() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('value')->nullable(); + $table->datetime('deleted_at')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['value' => 1, 'deleted_at' => null], + ['value' => 2, 'deleted_at' => '2021-12-01 00:00:00'], + ['value' => 3, 'deleted_at' => null], + ['value' => 4, 'deleted_at' => '2021-12-02 00:00:00'], + ]); + + $output = $this->artisan(['--model' => PrunableTestSoftDeletedModelWithPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +2 [Illuminate\Tests\Database\PrunableTestSoftDeletedModelWithPrunableRecords] records have been pruned. + +EOF, str_replace("\r", '', $output->fetch())); + + $this->assertEquals(2, PrunableTestSoftDeletedModelWithPrunableRecords::withTrashed()->count()); + } + + public function testNonPrunableTest() + { + $output = $this->artisan(['--model' => NonPrunableTestModel::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\NonPrunableTestModel] records found. + +EOF, str_replace("\r", '', $output->fetch())); + } + + public function testNonPrunableTestWithATrait() + { + $output = $this->artisan(['--model' => NonPrunableTrait::class]); + + $this->assertEquals(<<<'EOF' +No prunable models found. + +EOF, str_replace("\r", '', $output->fetch())); + } + + public function testTheCommandMayBePretended() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('name')->nullable(); + $table->string('value')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['name' => 'zain', 'value' => 1], + ['name' => 'patrice', 'value' => 2], + ['name' => 'amelia', 'value' => 3], + ['name' => 'stuart', 'value' => 4], + ['name' => 'bello', 'value' => 5], + ]); + + $output = $this->artisan([ + '--model' => PrunableTestModelWithPrunableRecords::class, + '--pretend' => true, + ]); + + $this->assertEquals(<<<'EOF' +3 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records will be pruned. + +EOF, str_replace("\r", '', $output->fetch())); + + $this->assertEquals(5, PrunableTestModelWithPrunableRecords::count()); + } + + public function testTheCommandMayBePretendedOnSoftDeletedModel() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('value')->nullable(); + $table->datetime('deleted_at')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['value' => 1, 'deleted_at' => null], + ['value' => 2, 'deleted_at' => '2021-12-01 00:00:00'], + ['value' => 3, 'deleted_at' => null], + ['value' => 4, 'deleted_at' => '2021-12-02 00:00:00'], + ]); + + $output = $this->artisan([ + '--model' => PrunableTestSoftDeletedModelWithPrunableRecords::class, + '--pretend' => true, + ]); + + $this->assertEquals(<<<'EOF' +2 [Illuminate\Tests\Database\PrunableTestSoftDeletedModelWithPrunableRecords] records will be pruned. + +EOF, str_replace("\r", '', $output->fetch())); + + $this->assertEquals(4, PrunableTestSoftDeletedModelWithPrunableRecords::withTrashed()->count()); + } + + protected function artisan($arguments) + { + $input = new ArrayInput($arguments); + $output = new BufferedOutput; + + tap(new PruneCommand()) + ->setLaravel(Container::getInstance()) + ->run($input, $output); + + return $output; + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + } +} + +class PrunableTestModelWithPrunableRecords extends Model +{ + use MassPrunable; + + protected $table = 'prunables'; + protected $connection = 'default'; + + public function pruneAll() + { + event(new ModelsPruned(static::class, 10)); + event(new ModelsPruned(static::class, 20)); + + return 20; + } + + public function prunable() + { + return static::where('value', '>=', 3); + } +} + +class PrunableTestSoftDeletedModelWithPrunableRecords extends Model +{ + use MassPrunable, SoftDeletes; + + protected $table = 'prunables'; + protected $connection = 'default'; + + public function prunable() + { + return static::where('value', '>=', 3); + } +} + +class PrunableTestModelWithoutPrunableRecords extends Model +{ + use Prunable; + + public function pruneAll() + { + return 0; + } +} + +class NonPrunableTestModel extends Model +{ + // .. +} + +trait NonPrunableTrait +{ + use Prunable; +} diff --git a/tests/Database/SeedCommandTest.php b/tests/Database/SeedCommandTest.php index 3b74ed2a25d89f889d053264ad698aab4a18a771..215990f30bd2ee89b496bf14c641619dbb4570a5 100644 --- a/tests/Database/SeedCommandTest.php +++ b/tests/Database/SeedCommandTest.php @@ -25,6 +25,7 @@ class SeedCommandTest extends TestCase $seeder->shouldReceive('__invoke')->once(); $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('getDefaultConnection')->once(); $resolver->shouldReceive('setDefaultConnection')->once()->with('sqlite'); $container = m::mock(Container::class); diff --git a/tests/Database/SqlServerBuilderTest.php b/tests/Database/SqlServerBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..039cadb8394a728785abd3841c427624d8f0e5c0 --- /dev/null +++ b/tests/Database/SqlServerBuilderTest.php @@ -0,0 +1,46 @@ +<?php + +namespace Illuminate\Tests\Database; + +use Illuminate\Database\Connection; +use Illuminate\Database\Schema\Grammars\SqlServerGrammar; +use Illuminate\Database\Schema\SqlServerBuilder; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class SqlServerBuilderTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testCreateDatabase() + { + $grammar = new SqlServerGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database "my_temporary_database_a"' + )->andReturn(true); + + $builder = new SqlServerBuilder($connection); + $builder->createDatabase('my_temporary_database_a'); + } + + public function testDropDatabaseIfExists() + { + $grammar = new SqlServerGrammar; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists "my_temporary_database_b"' + )->andReturn(true); + + $builder = new SqlServerBuilder($connection); + + $builder->dropDatabaseIfExists('my_temporary_database_b'); + } +} diff --git a/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php new file mode 100644 index 0000000000000000000000000000000000000000..c95b6f0e527d798b508fc5eaa95607cd6374ebc5 --- /dev/null +++ b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php @@ -0,0 +1,42 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * The database connection that should be used by the migration. + * + * @var string + */ + protected $connection = 'sqlite3'; + + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/tests/Database/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php b/tests/Database/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php new file mode 100644 index 0000000000000000000000000000000000000000..a4f3c54a59c129c699d8a73f01f796e15eb01334 --- /dev/null +++ b/tests/Database/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php @@ -0,0 +1,36 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::connection('sqlite3')->create('jobs', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::connection('sqlite3')->dropIfExists('jobs'); + } +}; diff --git a/tests/Database/stubs/EloquentModelUuidStub.php b/tests/Database/stubs/EloquentModelUuidStub.php new file mode 100644 index 0000000000000000000000000000000000000000..b8a7251144ba1f755273e6bf74034999bda17a7c --- /dev/null +++ b/tests/Database/stubs/EloquentModelUuidStub.php @@ -0,0 +1,27 @@ +<?php + +use Illuminate\Database\Eloquent\Model; + +class EloquentModelUuidStub extends Model +{ + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'model'; + + /** + * The "type" of the primary key ID. + * + * @var string + */ + protected $keyType = 'string'; + + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; +} diff --git a/tests/Database/stubs/TestEnum.php b/tests/Database/stubs/TestEnum.php new file mode 100644 index 0000000000000000000000000000000000000000..fa154c939c29f4ed077e4d045d57f44db1e8a307 --- /dev/null +++ b/tests/Database/stubs/TestEnum.php @@ -0,0 +1,8 @@ +<?php + +namespace Illuminate\Tests\Database\stubs; + +enum TestEnum: string +{ + case test = 'test'; +} diff --git a/tests/Database/stubs/schema.sql b/tests/Database/stubs/schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php index 4380c383da2e30c33036b30db8c848f14aa38fa6..ee6d5cfdc47ccc209c2fae799e8150d49890af94 100755 --- a/tests/Encryption/EncrypterTest.php +++ b/tests/Encryption/EncrypterTest.php @@ -33,23 +33,94 @@ class EncrypterTest extends TestCase $this->assertSame('foo', $e->decrypt($encrypted)); } + public function testEncryptedLengthIsFixed() + { + $e = new Encrypter(str_repeat('a', 16)); + $lengths = []; + for ($i = 0; $i < 100; $i++) { + $lengths[] = strlen($e->encrypt('foo')); + } + $this->assertSame(min($lengths), max($lengths)); + } + public function testWithCustomCipher() { - $e = new Encrypter(str_repeat('b', 32), 'AES-256-CBC'); + $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('bar'); $this->assertNotSame('bar', $encrypted); $this->assertSame('bar', $e->decrypt($encrypted)); - $e = new Encrypter(random_bytes(32), 'AES-256-CBC'); + $e = new Encrypter(random_bytes(32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); $this->assertNotSame('foo', $encrypted); $this->assertSame('foo', $e->decrypt($encrypted)); } + public function testCipherNamesCanBeMixedCase() + { + $upper = new Encrypter(str_repeat('b', 16), 'AES-128-GCM'); + $encrypted = $upper->encrypt('bar'); + $this->assertNotSame('bar', $encrypted); + + $lower = new Encrypter(str_repeat('b', 16), 'aes-128-gcm'); + $this->assertSame('bar', $lower->decrypt($encrypted)); + + $mixed = new Encrypter(str_repeat('b', 16), 'aEs-128-GcM'); + $this->assertSame('bar', $mixed->decrypt($encrypted)); + } + + public function testThatAnAeadCipherIncludesTag() + { + $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); + $encrypted = $e->encrypt('foo'); + $data = json_decode(base64_decode($encrypted)); + + $this->assertEmpty($data->mac); + $this->assertNotEmpty($data->tag); + } + + public function testThatAnAeadTagMustBeProvidedInFullLength() + { + $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); + $encrypted = $e->encrypt('foo'); + $data = json_decode(base64_decode($encrypted)); + + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('Could not decrypt the data.'); + + $data->tag = substr($data->tag, 0, 4); + $encrypted = base64_encode(json_encode($data)); + $e->decrypt($encrypted); + } + + public function testThatAnAeadTagCantBeModified() + { + $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); + $encrypted = $e->encrypt('foo'); + $data = json_decode(base64_decode($encrypted)); + + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('Could not decrypt the data.'); + + $data->tag[0] = $data->tag[0] === 'A' ? 'B' : 'A'; + $encrypted = base64_encode(json_encode($data)); + $e->decrypt($encrypted); + } + + public function testThatANonAeadCipherIncludesMac() + { + $e = new Encrypter(str_repeat('b', 32), 'AES-256-CBC'); + $encrypted = $e->encrypt('foo'); + $data = json_decode(base64_decode($encrypted)); + + $this->assertEmpty($data->tag); + $this->assertNotEmpty($data->mac); + } + public function testDoNoAllowLongerKey() { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'); + $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); new Encrypter(str_repeat('z', 32)); } @@ -57,7 +128,7 @@ class EncrypterTest extends TestCase public function testWithBadKeyLength() { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'); + $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); new Encrypter(str_repeat('a', 5)); } @@ -65,15 +136,15 @@ class EncrypterTest extends TestCase public function testWithBadKeyLengthAlternativeCipher() { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'); + $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); - new Encrypter(str_repeat('a', 16), 'AES-256-CFB8'); + new Encrypter(str_repeat('a', 16), 'AES-256-GCM'); } public function testWithUnsupportedCipher() { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'); + $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); new Encrypter(str_repeat('c', 16), 'AES-256-CFB8'); } @@ -89,6 +160,18 @@ class EncrypterTest extends TestCase $e->decrypt($payload); } + public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded() + { + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('Unable to use tag because the cipher algorithm does not support AEAD.'); + + $e = new Encrypter(str_repeat('a', 16)); + $payload = $e->encrypt('foo'); + $decodedPayload = json_decode(base64_decode($payload)); + $decodedPayload->tag = 'set-manually'; + $e->decrypt(base64_encode(json_encode($decodedPayload))); + } + public function testExceptionThrownWithDifferentKey() { $this->expectException(DecryptException::class); @@ -112,4 +195,13 @@ class EncrypterTest extends TestCase $modified_payload = base64_encode(json_encode($data)); $e->decrypt($modified_payload); } + + public function testSupportedMethodAcceptsAnyCasing() + { + $key = str_repeat('a', 16); + + $this->assertTrue(Encrypter::supported($key, 'AES-128-GCM')); + $this->assertTrue(Encrypter::supported($key, 'aes-128-CBC')); + $this->assertTrue(Encrypter::supported($key, 'aes-128-cbc')); + } } diff --git a/tests/Events/BroadcastedEventsTest.php b/tests/Events/BroadcastedEventsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c241938364ba01dc7cae1c4bab8acdf894498325 --- /dev/null +++ b/tests/Events/BroadcastedEventsTest.php @@ -0,0 +1,94 @@ +<?php + +namespace Illuminate\Tests\Events; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Events\Dispatcher; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class BroadcastedEventsTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testShouldBroadcastSuccess() + { + $d = m::mock(Dispatcher::class); + + $d->makePartial()->shouldAllowMockingProtectedMethods(); + + $event = new BroadcastEvent; + + $this->assertTrue($d->shouldBroadcast([$event])); + + $event = new AlwaysBroadcastEvent; + + $this->assertTrue($d->shouldBroadcast([$event])); + } + + public function testShouldBroadcastAsQueuedAndCallNormalListeners() + { + unset($_SERVER['__event.test']); + $d = new Dispatcher($container = m::mock(Container::class)); + $broadcast = m::mock(BroadcastFactory::class); + $broadcast->shouldReceive('queue')->once(); + $container->shouldReceive('make')->once()->with(BroadcastFactory::class)->andReturn($broadcast); + + $d->listen(AlwaysBroadcastEvent::class, function ($payload) { + $_SERVER['__event.test'] = $payload; + }); + + $d->dispatch($e = new AlwaysBroadcastEvent); + + $this->assertSame($e, $_SERVER['__event.test']); + } + + public function testShouldBroadcastFail() + { + $d = m::mock(Dispatcher::class); + + $d->makePartial()->shouldAllowMockingProtectedMethods(); + + $event = new BroadcastFalseCondition; + + $this->assertFalse($d->shouldBroadcast([$event])); + + $event = new ExampleEvent; + + $this->assertFalse($d->shouldBroadcast([$event])); + } +} + +class BroadcastEvent implements ShouldBroadcast +{ + public function broadcastOn() + { + return ['test-channel']; + } + + public function broadcastWhen() + { + return true; + } +} + +class AlwaysBroadcastEvent implements ShouldBroadcast +{ + public function broadcastOn() + { + return ['test-channel']; + } +} + +class BroadcastFalseCondition extends BroadcastEvent +{ + public function broadcastWhen() + { + return false; + } +} diff --git a/tests/Events/EventsDispatcherTest.php b/tests/Events/EventsDispatcherTest.php index 8880ddcd7eb7d618a2e71c27ba4309076e6ff63f..ea09fe576ca7c4c315d2b89a6c4efef807f452c9 100755 --- a/tests/Events/EventsDispatcherTest.php +++ b/tests/Events/EventsDispatcherTest.php @@ -4,13 +4,7 @@ namespace Illuminate\Tests\Events; use Exception; use Illuminate\Container\Container; -use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Contracts\Queue\Queue; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Events\CallQueuedListener; use Illuminate\Events\Dispatcher; -use Illuminate\Support\Testing\Fakes\QueueFake; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -32,6 +26,14 @@ class EventsDispatcherTest extends TestCase $this->assertEquals([null], $response); $this->assertSame('bar', $_SERVER['__event.test']); + + // we can still add listeners after the event has fired + $d->listen('foo', function ($foo) { + $_SERVER['__event.test'] .= $foo; + }); + + $d->dispatch('foo', ['bar']); + $this->assertSame('barbar', $_SERVER['__event.test']); } public function testHaltingEventExecution() @@ -48,10 +50,10 @@ class EventsDispatcherTest extends TestCase }); $response = $d->dispatch('foo', ['bar'], true); - $this->assertEquals('here', $response); + $this->assertSame('here', $response); $response = $d->until('foo', ['bar']); - $this->assertEquals('here', $response); + $this->assertSame('here', $response); } public function testResponseWhenNoListenersAreSet() @@ -113,9 +115,8 @@ class EventsDispatcherTest extends TestCase public function testContainerResolutionOfEventHandlers() { $d = new Dispatcher($container = m::mock(Container::class)); - $container->shouldReceive('make')->once()->with('FooHandler')->andReturn($handler = m::mock(stdClass::class)); - $handler->shouldReceive('onFooEvent')->once()->with('foo', 'bar')->andReturn('baz'); - $d->listen('foo', 'FooHandler@onFooEvent'); + $container->shouldReceive('make')->once()->with(TestEventListener::class)->andReturn(new TestEventListener); + $d->listen('foo', TestEventListener::class.'@onFooEvent'); $response = $d->dispatch('foo', ['foo', 'bar']); $this->assertEquals(['baz'], $response); @@ -123,11 +124,10 @@ class EventsDispatcherTest extends TestCase public function testContainerResolutionOfEventHandlersWithDefaultMethods() { - $d = new Dispatcher($container = m::mock(Container::class)); - $container->shouldReceive('make')->once()->with('FooHandler')->andReturn($handler = m::mock(stdClass::class)); - $handler->shouldReceive('handle')->once()->with('foo', 'bar'); - $d->listen('foo', 'FooHandler'); - $d->dispatch('foo', ['foo', 'bar']); + $d = new Dispatcher(new Container); + $d->listen('foo', TestEventListener::class); + $response = $d->dispatch('foo', ['foo', 'bar']); + $this->assertEquals(['baz'], $response); } public function testQueuedEventsAreFired() @@ -320,49 +320,28 @@ class EventsDispatcherTest extends TestCase $d->dispatch('foo.bar', ['first', 'second']); } - public function testQueuedEventHandlersAreQueued() - { - $d = new Dispatcher; - $queue = m::mock(Queue::class); - - $queue->shouldReceive('connection')->once()->with(null)->andReturnSelf(); - - $queue->shouldReceive('pushOn')->once()->with(null, m::type(CallQueuedListener::class)); - - $d->setQueueResolver(function () use ($queue) { - return $queue; - }); - - $d->listen('some.event', TestDispatcherQueuedHandler::class.'@someMethod'); - $d->dispatch('some.event', ['foo', 'bar']); - } - - public function testCustomizedQueuedEventHandlersAreQueued() + public function testClassesWork() { + unset($_SERVER['__event.test']); $d = new Dispatcher; - - $fakeQueue = new QueueFake(new Container()); - - $d->setQueueResolver(function () use ($fakeQueue) { - return $fakeQueue; + $d->listen(ExampleEvent::class, function () { + $_SERVER['__event.test'] = 'baz'; }); + $d->dispatch(new ExampleEvent); - $d->listen('some.event', TestDispatcherConnectionQueuedHandler::class.'@handle'); - $d->dispatch('some.event', ['foo', 'bar']); - - $fakeQueue->assertPushedOn('my_queue', CallQueuedListener::class); + $this->assertSame('baz', $_SERVER['__event.test']); } - public function testClassesWork() + public function testClassesWorkWithAnonymousListeners() { unset($_SERVER['__event.test']); $d = new Dispatcher; - $d->listen(ExampleEvent::class, function () { - $_SERVER['__event.test'] = 'baz'; + $d->listen(function (ExampleEvent $event) { + $_SERVER['__event.test'] = 'qux'; }); $d->dispatch(new ExampleEvent); - $this->assertSame('baz', $_SERVER['__event.test']); + $this->assertSame('qux', $_SERVER['__event.test']); } public function testEventClassesArePayload() @@ -409,111 +388,40 @@ class EventsDispatcherTest extends TestCase $this->assertSame('fooo', $_SERVER['__event.test1']); $this->assertSame('baar', $_SERVER['__event.test2']); - unset($_SERVER['__event.test1']); - unset($_SERVER['__event.test2']); + unset($_SERVER['__event.test1'], $_SERVER['__event.test2']); } - public function testShouldBroadcastSuccess() + public function testNestedEvent() { - $d = m::mock(Dispatcher::class); - - $d->makePartial()->shouldAllowMockingProtectedMethods(); - - $event = new BroadcastEvent; - - $this->assertTrue($d->shouldBroadcast([$event])); - - $event = new AlwaysBroadcastEvent; - - $this->assertTrue($d->shouldBroadcast([$event])); - } - - public function testShouldBroadcastAsQueuedAndCallNormalListeners() - { - unset($_SERVER['__event.test']); - $d = new Dispatcher($container = m::mock(Container::class)); - $broadcast = m::mock(BroadcastFactory::class); - $broadcast->shouldReceive('queue')->once(); - $container->shouldReceive('make')->once()->with(BroadcastFactory::class)->andReturn($broadcast); + $_SERVER['__event.test'] = []; + $d = new Dispatcher; - $d->listen(AlwaysBroadcastEvent::class, function ($payload) { - $_SERVER['__event.test'] = $payload; + $d->listen('event', function () use ($d) { + $d->listen('event', function () { + $_SERVER['__event.test'][] = 'fired 1'; + }); + $d->listen('event', function () { + $_SERVER['__event.test'][] = 'fired 2'; + }); }); - $d->dispatch($e = new AlwaysBroadcastEvent); - - $this->assertSame($e, $_SERVER['__event.test']); - } - - public function testShouldBroadcastFail() - { - $d = m::mock(Dispatcher::class); - - $d->makePartial()->shouldAllowMockingProtectedMethods(); - - $event = new BroadcastFalseCondition; - - $this->assertFalse($d->shouldBroadcast([$event])); - - $event = new ExampleEvent; - - $this->assertFalse($d->shouldBroadcast([$event])); - } - - public function testEventSubscribers() - { - $d = new Dispatcher($container = m::mock(Container::class)); - $subs = m::mock(ExampleSubscriber::class); - $subs->shouldReceive('subscribe')->once()->with($d); - $container->shouldReceive('make')->once()->with(ExampleSubscriber::class)->andReturn($subs); - - $d->subscribe(ExampleSubscriber::class); - $this->assertTrue(true); - } - - public function testEventSubscribeCanAcceptObject() - { - $d = new Dispatcher(); - $subs = m::mock(ExampleSubscriber::class); - $subs->shouldReceive('subscribe')->once()->with($d); - - $d->subscribe($subs); - $this->assertTrue(true); - } -} - -class TestDispatcherQueuedHandler implements ShouldQueue -{ - public function handle() - { - // - } -} - -class TestDispatcherConnectionQueuedHandler implements ShouldQueue -{ - public $connection = 'redis'; - - public $delay = 10; - - public $queue = 'my_queue'; - - public function handle() - { - // + $d->dispatch('event'); + $this->assertSame([], $_SERVER['__event.test']); + $d->dispatch('event'); + $this->assertEquals(['fired 1', 'fired 2'], $_SERVER['__event.test']); } -} -class TestDispatcherQueuedHandlerCustomQueue implements ShouldQueue -{ - public function handle() + public function testDuplicateListenersWillFire() { - // - } + $d = new Dispatcher; + $d->listen('event', TestListener::class); + $d->listen('event', TestListener::class); + $d->listen('event', TestListener::class.'@handle'); + $d->listen('event', TestListener::class.'@handle'); + $d->dispatch('event'); - public function queue($queue, $handler, array $payload) - { - $queue->push($handler, $payload); + $this->assertEquals(4, TestListener::$counter); + TestListener::$counter = 0; } } @@ -522,14 +430,6 @@ class ExampleEvent // } -class ExampleSubscriber -{ - public function subscribe($e) - { - // - } -} - interface SomeEventInterface { // @@ -540,31 +440,25 @@ class AnotherEvent implements SomeEventInterface // } -class BroadcastEvent implements ShouldBroadcast +class TestEventListener { - public function broadcastOn() + public function handle($foo, $bar) { - return ['test-channel']; + return 'baz'; } - public function broadcastWhen() + public function onFooEvent($foo, $bar) { - return true; + return 'baz'; } } -class AlwaysBroadcastEvent implements ShouldBroadcast +class TestListener { - public function broadcastOn() - { - return ['test-channel']; - } -} + public static $counter = 0; -class BroadcastFalseCondition extends BroadcastEvent -{ - public function broadcastWhen() + public function handle() { - return false; + self::$counter++; } } diff --git a/tests/Events/EventsSubscriberTest.php b/tests/Events/EventsSubscriberTest.php new file mode 100644 index 0000000000000000000000000000000000000000..545b7ee7dcc7d749ce44e540200ee9e7d5c9e300 --- /dev/null +++ b/tests/Events/EventsSubscriberTest.php @@ -0,0 +1,93 @@ +<?php + +namespace Illuminate\Tests\Events; + +use Illuminate\Container\Container; +use Illuminate\Events\Dispatcher; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class EventsSubscriberTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testEventSubscribers() + { + $this->expectNotToPerformAssertions(); + + $d = new Dispatcher($container = m::mock(Container::class)); + $subs = m::mock(ExampleSubscriber::class); + $subs->shouldReceive('subscribe')->once()->with($d); + $container->shouldReceive('make')->once()->with(ExampleSubscriber::class)->andReturn($subs); + + $d->subscribe(ExampleSubscriber::class); + } + + public function testEventSubscribeCanAcceptObject() + { + $this->expectNotToPerformAssertions(); + + $d = new Dispatcher; + $subs = m::mock(ExampleSubscriber::class); + $subs->shouldReceive('subscribe')->once()->with($d); + + $d->subscribe($subs); + } + + public function testEventSubscribeCanReturnMappings() + { + $d = new Dispatcher; + $d->subscribe(DeclarativeSubscriber::class); + + $d->dispatch('myEvent1'); + $this->assertSame('L1_L2_', DeclarativeSubscriber::$string); + + $d->dispatch('myEvent2'); + $this->assertSame('L1_L2_L3', DeclarativeSubscriber::$string); + } +} + +class ExampleSubscriber +{ + public function subscribe($e) + { + // There would be no error if a non-array is returned. + return '(O_o)'; + } +} + +class DeclarativeSubscriber +{ + public static $string = ''; + + public function subscribe() + { + return [ + 'myEvent1' => [ + self::class.'@listener1', + self::class.'@listener2', + ], + 'myEvent2' => [ + self::class.'@listener3', + ], + ]; + } + + public function listener1() + { + self::$string .= 'L1_'; + } + + public function listener2() + { + self::$string .= 'L2_'; + } + + public function listener3() + { + self::$string .= 'L3'; + } +} diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4c5ea4b66b6d344397e314668976b211eb21d2cd --- /dev/null +++ b/tests/Events/QueuedEventsTest.php @@ -0,0 +1,210 @@ +<?php + +namespace Illuminate\Tests\Events; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Queue\Queue; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Events\CallQueuedListener; +use Illuminate\Events\Dispatcher; +use Illuminate\Support\Testing\Fakes\QueueFake; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class QueuedEventsTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + } + + public function testQueuedEventHandlersAreQueued() + { + $d = new Dispatcher; + $queue = m::mock(Queue::class); + + $queue->shouldReceive('connection')->once()->with(null)->andReturnSelf(); + + $queue->shouldReceive('pushOn')->once()->with(null, m::type(CallQueuedListener::class)); + + $d->setQueueResolver(function () use ($queue) { + return $queue; + }); + + $d->listen('some.event', TestDispatcherQueuedHandler::class.'@someMethod'); + $d->dispatch('some.event', ['foo', 'bar']); + } + + public function testCustomizedQueuedEventHandlersAreQueued() + { + $d = new Dispatcher; + + $fakeQueue = new QueueFake(new Container); + + $d->setQueueResolver(function () use ($fakeQueue) { + return $fakeQueue; + }); + + $d->listen('some.event', TestDispatcherConnectionQueuedHandler::class.'@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + + $fakeQueue->assertPushedOn('my_queue', CallQueuedListener::class); + } + + public function testQueueIsSetByGetQueue() + { + $d = new Dispatcher; + + $fakeQueue = new QueueFake(new Container); + + $d->setQueueResolver(function () use ($fakeQueue) { + return $fakeQueue; + }); + + $d->listen('some.event', TestDispatcherGetQueue::class.'@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + + $fakeQueue->assertPushedOn('some_other_queue', CallQueuedListener::class); + } + + public function testQueueIsSetByGetConnection() + { + $d = new Dispatcher; + $queue = m::mock(Queue::class); + + $queue->shouldReceive('connection')->once()->with('some_other_connection')->andReturnSelf(); + + $queue->shouldReceive('pushOn')->once()->with(null, m::type(CallQueuedListener::class)); + + $d->setQueueResolver(function () use ($queue) { + return $queue; + }); + + $d->listen('some.event', TestDispatcherGetConnection::class.'@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + } + + public function testQueuePropagateRetryUntilAndMaxExceptions() + { + $d = new Dispatcher; + + $fakeQueue = new QueueFake(new Container); + + $d->setQueueResolver(function () use ($fakeQueue) { + return $fakeQueue; + }); + + $d->listen('some.event', TestDispatcherOptions::class.'@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + + $fakeQueue->assertPushed(CallQueuedListener::class, function ($job) { + return $job->maxExceptions === 1 && $job->retryUntil !== null; + }); + } + + public function testQueuePropagateMiddleware() + { + $d = new Dispatcher; + + $fakeQueue = new QueueFake(new Container); + + $d->setQueueResolver(function () use ($fakeQueue) { + return $fakeQueue; + }); + + $d->listen('some.event', TestDispatcherMiddleware::class.'@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + + $fakeQueue->assertPushed(CallQueuedListener::class, function ($job) { + return count($job->middleware) === 1 && $job->middleware[0] instanceof TestMiddleware; + }); + } +} + +class TestDispatcherQueuedHandler implements ShouldQueue +{ + public function handle() + { + // + } +} + +class TestDispatcherConnectionQueuedHandler implements ShouldQueue +{ + public $connection = 'redis'; + + public $delay = 10; + + public $queue = 'my_queue'; + + public function handle() + { + // + } +} + +class TestDispatcherGetQueue implements ShouldQueue +{ + public $queue = 'my_queue'; + + public function handle() + { + // + } + + public function viaQueue() + { + return 'some_other_queue'; + } +} + +class TestDispatcherGetConnection implements ShouldQueue +{ + public $connection = 'my_connection'; + + public function handle() + { + // + } + + public function viaConnection() + { + return 'some_other_connection'; + } +} + +class TestDispatcherOptions implements ShouldQueue +{ + public $maxExceptions = 1; + + public function retryUntil() + { + return now()->addHour(1); + } + + public function handle() + { + // + } +} + +class TestDispatcherMiddleware implements ShouldQueue +{ + public function middleware() + { + return [new TestMiddleware()]; + } + + public function handle() + { + // + } +} + +class TestMiddleware +{ + public function handle($job, $next) + { + $next($job); + } +} diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index e7dffba0b76f16ab89401f9a1873c4fbebfdc6e9..440c0366328382e3b19d3e097d0343700b751582 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -2,12 +2,13 @@ namespace Illuminate\Tests\Filesystem; +use Carbon\Carbon; use GuzzleHttp\Psr7\Stream; use Illuminate\Contracts\Filesystem\FileExistsException; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Filesystem\FilesystemAdapter; -use Illuminate\Foundation\Testing\Assert; use Illuminate\Http\UploadedFile; +use Illuminate\Testing\Assert; use InvalidArgumentException; use League\Flysystem\Adapter\Local; use League\Flysystem\Filesystem; @@ -242,7 +243,7 @@ class FilesystemAdapterTest extends TestCase fclose($stream); $spy->shouldHaveReceived('putStream'); - $this->assertEquals('some-data', $filesystemAdapter->get('bar.txt')); + $this->assertSame('some-data', $filesystemAdapter->get('bar.txt')); } public function testPutFileAs() @@ -262,6 +263,11 @@ class FilesystemAdapterTest extends TestCase $filesystemAdapter->assertExists($storagePath); $this->assertSame('uploaded file content', $filesystemAdapter->read($storagePath)); + + $filesystemAdapter->assertExists( + $storagePath, + 'uploaded file content' + ); } public function testPutFileAsWithAbsoluteFilePath() @@ -290,6 +296,11 @@ class FilesystemAdapterTest extends TestCase $this->assertFileExists($filePath); $filesystemAdapter->assertExists($storagePath); + + $filesystemAdapter->assertExists( + $storagePath, + 'uploaded file content' + ); } public function testPutFileWithAbsoluteFilePath() @@ -303,5 +314,40 @@ class FilesystemAdapterTest extends TestCase $this->assertSame(44, strlen($storagePath)); // random 40 characters + ".txt" $filesystemAdapter->assertExists($storagePath); + + $filesystemAdapter->assertExists( + $storagePath, + 'uploaded file content' + ); + } + + public function testMacroable() + { + $this->filesystem->write('foo.txt', 'Hello World'); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem); + $filesystemAdapter->macro('getFoo', function () { + return $this->get('foo.txt'); + }); + + $this->assertSame('Hello World', $filesystemAdapter->getFoo()); + } + + public function testTemporaryUrlWithCustomCallback() + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem); + + $filesystemAdapter->buildTemporaryUrlsUsing(function ($path, Carbon $expiration, $options) { + return $path.$expiration->toString().implode('', $options); + }); + + $path = 'foo'; + $expiration = Carbon::create(2021, 18, 12, 13); + $options = ['bar' => 'baz']; + + $this->assertSame( + $path.$expiration->toString().implode('', $options), + $filesystemAdapter->temporaryUrl($path, $expiration, $options) + ); } } diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 3c3d46b6c9c2f14ac746ab017fbab41fdc2fd959..8924e23b1a95f4506716de6335e0b5ca58464e45 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Filesystem; +use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; use InvalidArgumentException; @@ -20,4 +21,20 @@ class FilesystemManagerTest extends TestCase $filesystem->disk('local'); } + + public function testCanBuildOnDemandDisk() + { + $filesystem = new FilesystemManager(new Application); + + $this->assertInstanceOf(Filesystem::class, $filesystem->build('my-custom-path')); + + $this->assertInstanceOf(Filesystem::class, $filesystem->build([ + 'driver' => 'local', + 'root' => 'my-custom-path', + 'url' => 'my-custom-url', + 'visibility' => 'public', + ])); + + rmdir(__DIR__.'/../../my-custom-path'); + } } diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index ed4e5750f52fb13a18b7534b5f1feb9ceff5ef6e..ef36f103fd2309a317bfa77b513915b8388a9045 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -6,7 +6,8 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; -use Illuminate\Foundation\Testing\Assert; +use Illuminate\Support\LazyCollection; +use Illuminate\Testing\Assert; use Mockery as m; use PHPUnit\Framework\TestCase; use SplFileInfo; @@ -20,7 +21,7 @@ class FilesystemTest extends TestCase */ public static function setUpTempDir() { - self::$tempDir = __DIR__.'/tmp'; + self::$tempDir = sys_get_temp_dir().'/tmp'; mkdir(self::$tempDir); } @@ -56,6 +57,27 @@ class FilesystemTest extends TestCase $this->assertStringEqualsFile(self::$tempDir.'/file.txt', 'Hello World'); } + public function testLines() + { + $path = self::$tempDir.'/file.txt'; + + $contents = LazyCollection::times(3) + ->map(function ($number) { + return "line-{$number}"; + }) + ->join("\n"); + + file_put_contents($path, $contents); + + $files = new Filesystem; + $this->assertInstanceOf(LazyCollection::class, $files->lines($path)); + + $this->assertSame( + ['line-1', 'line-2', 'line-3'], + $files->lines($path)->all() + ); + } + public function testReplaceCreatesFile() { $tempFile = self::$tempDir.'/file.txt'; @@ -66,12 +88,22 @@ class FilesystemTest extends TestCase $this->assertStringEqualsFile($tempFile, 'Hello World'); } - public function testReplaceWhenUnixSymlinkExists() + public function testReplaceInFileCorrectlyReplaces() { - if (windows_os()) { - $this->markTestSkipped('The operating system is Windows'); - } + $tempFile = self::$tempDir.'/file.txt'; + $filesystem = new Filesystem; + + $filesystem->put($tempFile, 'Hello World'); + $filesystem->replaceInFile('Hello World', 'Hello Taylor', $tempFile); + $this->assertStringEqualsFile($tempFile, 'Hello Taylor'); + } + + /** + * @requires OS Linux|Darwin + */ + public function testReplaceWhenUnixSymlinkExists() + { $tempFile = self::$tempDir.'/file.txt'; $symlinkDir = self::$tempDir.'/symlink_dir'; $symlink = "{$symlinkDir}/symlink.txt"; @@ -115,7 +147,7 @@ class FilesystemTest extends TestCase $files = new Filesystem; $files->chmod(self::$tempDir.'/file.txt', 0755); $filePermission = substr(sprintf('%o', fileperms(self::$tempDir.'/file.txt')), -4); - $expectedPermissions = DIRECTORY_SEPARATOR == '\\' ? '0666' : '0755'; + $expectedPermissions = DIRECTORY_SEPARATOR === '\\' ? '0666' : '0755'; $this->assertEquals($expectedPermissions, $filePermission); } @@ -126,7 +158,7 @@ class FilesystemTest extends TestCase $files = new Filesystem; $filePermission = $files->chmod(self::$tempDir.'/file.txt'); - $expectedPermissions = DIRECTORY_SEPARATOR == '\\' ? '0666' : '0755'; + $expectedPermissions = DIRECTORY_SEPARATOR === '\\' ? '0666' : '0755'; $this->assertEquals($expectedPermissions, $filePermission); } @@ -462,14 +494,10 @@ class FilesystemTest extends TestCase /** * @requires extension pcntl - * @requires function pcntl_fork + * @requires OS Linux|Darwin */ public function testSharedGet() { - if (PHP_OS == 'Darwin') { - $this->markTestSkipped('The operating system is MacOS.'); - } - $content = str_repeat('123456', 1000000); $result = 1; diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..102827613c1815960a6856ee48d68b0cb0833216 --- /dev/null +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -0,0 +1,225 @@ +<?php + +namespace Illuminate\Tests\Foundation\Bootstrap; + +use ErrorException; +use Illuminate\Config\Repository as Config; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Bootstrap\HandleExceptions; +use Illuminate\Log\LogManager; +use Mockery as m; +use Monolog\Handler\NullHandler; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +class HandleExceptionsTest extends TestCase +{ + protected function setUp(): void + { + $this->app = Application::setInstance(new Application); + + $this->config = new Config(); + + $this->app->singleton('config', function () { + return $this->config; + }); + + $this->handleExceptions = new HandleExceptions(); + + with(new ReflectionClass($this->handleExceptions), function ($reflection) { + $property = tap($reflection->getProperty('app'))->setAccessible(true); + + $property->setValue( + $this->handleExceptions, + tap(m::mock($this->app), function ($app) { + $app->shouldReceive('runningUnitTests')->andReturn(false); + $app->shouldReceive('hasBeenBootstrapped')->andReturn(true); + }) + ); + }); + } + + protected function tearDown(): void + { + Application::setInstance(null); + } + + public function testPhpDeprecations() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->with('deprecations')->andReturnSelf(); + $logger->shouldReceive('warning')->with(sprintf('%s in %s on line %s', + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + )); + + $this->handleExceptions->handleError( + E_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + } + + public function testUserDeprecations() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->with('deprecations')->andReturnSelf(); + $logger->shouldReceive('warning')->with(sprintf('%s in %s on line %s', + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + )); + + $this->handleExceptions->handleError( + E_USER_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + } + + public function testErrors() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldNotReceive('channel'); + $logger->shouldNotReceive('warning'); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Something went wrong'); + + $this->handleExceptions->handleError( + E_ERROR, + 'Something went wrong', + '/home/user/laravel/src/Providers/AppServiceProvider.php', + 17 + ); + } + + public function testEnsuresDeprecationsDriver() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->andReturnSelf(); + $logger->shouldReceive('warning'); + + $this->config->set('logging.channels.stack', [ + 'driver' => 'stack', + 'channels' => ['single'], + 'ignore_exceptions' => false, + ]); + $this->config->set('logging.deprecations', 'stack'); + + $this->handleExceptions->handleError( + E_USER_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + + $this->assertEquals( + [ + 'driver' => 'stack', + 'channels' => ['single'], + 'ignore_exceptions' => false, + ], + $this->config->get('logging.channels.deprecations') + ); + } + + public function testEnsuresNullDeprecationsDriver() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->andReturnSelf(); + $logger->shouldReceive('warning'); + + $this->handleExceptions->handleError( + E_USER_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + + $this->assertEquals( + NullHandler::class, + $this->config->get('logging.channels.deprecations.handler') + ); + } + + public function testEnsuresNullLogDriver() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->andReturnSelf(); + $logger->shouldReceive('warning'); + + $this->handleExceptions->handleError( + E_USER_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + + $this->assertEquals( + NullHandler::class, + $this->config->get('logging.channels.deprecations.handler') + ); + } + + public function testDoNotOverrideExistingNullLogDriver() + { + $logger = m::mock(LogManager::class); + $this->app->instance(LogManager::class, $logger); + + $logger->shouldReceive('channel')->andReturnSelf(); + $logger->shouldReceive('warning'); + + $this->config->set('logging.channels.null', [ + 'driver' => 'monolog', + 'handler' => CustomNullHandler::class, + ]); + + $this->handleExceptions->handleError( + E_USER_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + + $this->assertEquals( + CustomNullHandler::class, + $this->config->get('logging.channels.deprecations.handler') + ); + } + + public function testNoDeprecationsDriverIfNoDeprecationsHereSend() + { + $this->assertEquals(null, $this->config->get('logging.deprecations')); + $this->assertEquals(null, $this->config->get('logging.channels.deprecations')); + } + + public function testIgnoreDeprecationIfLoggerUnresolvable() + { + $this->handleExceptions->handleError( + E_DEPRECATED, + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/laravel/routes/web.php', + 17 + ); + } +} + +class CustomNullHandler extends NullHandler +{ +} diff --git a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php index 9720273fa8bbda52961784a82d78540fc6fc1f65..8b75e0cbdf0aba84818b3ecb77c150c8b64def04 100644 --- a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php +++ b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Foundation; +namespace Illuminate\Tests\Foundation\Bootstrap; use Illuminate\Foundation\Application; use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; @@ -11,8 +11,7 @@ class LoadEnvironmentVariablesTest extends TestCase { protected function tearDown(): void { - unset($_ENV['FOO']); - unset($_SERVER['FOO']); + unset($_ENV['FOO'], $_SERVER['FOO']); putenv('FOO'); m::close(); } diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 582248e887cadd2a1ba7c43f3a743a07404ff12e..63abffc4e3efef82f50bfacbf1dd4d417ad523b7 100755 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Foundation; +use Illuminate\Config\Repository; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Foundation\Application; use Illuminate\Foundation\Bootstrap\RegisterFacades; @@ -45,7 +46,8 @@ class FoundationApplicationTest extends TestCase public function testClassesAreBoundWhenServiceProviderIsRegistered() { $app = new Application; - $app->register($provider = new class($app) extends ServiceProvider { + $app->register($provider = new class($app) extends ServiceProvider + { public $bindings = [ AbstractClass::class => ConcreteClass::class, ]; @@ -62,7 +64,8 @@ class FoundationApplicationTest extends TestCase public function testSingletonsAreCreatedWhenServiceProviderIsRegistered() { $app = new Application; - $app->register($provider = new class($app) extends ServiceProvider { + $app->register($provider = new class($app) extends ServiceProvider + { public $singletons = [ AbstractClass::class => ConcreteClass::class, ]; @@ -87,6 +90,18 @@ class FoundationApplicationTest extends TestCase $this->assertArrayHasKey($class, $app->getLoadedProviders()); } + public function testServiceProvidersCouldBeLoaded() + { + $provider = m::mock(ServiceProvider::class); + $class = get_class($provider); + $provider->shouldReceive('register')->once(); + $app = new Application; + $app->register($provider); + + $this->assertTrue($app->providerIsLoaded($class)); + $this->assertFalse($app->providerIsLoaded(ApplicationBasicServiceProviderStub::class)); + } + public function testDeferredServicesMarkedAsBound() { $app = new Application; @@ -133,7 +148,7 @@ class FoundationApplicationTest extends TestCase $app->setDeferredServices(['foo' => ApplicationDeferredServiceProviderStub::class]); $app->instance('foo', 'bar'); $instance = $app->make('foo'); - $this->assertEquals($instance, 'bar'); + $this->assertSame('bar', $instance); } public function testDeferredServicesAreLazilyInitialized() @@ -180,7 +195,7 @@ class FoundationApplicationTest extends TestCase SampleImplementation::class => SampleImplementationDeferredServiceProvider::class, ]); $instance = $app->make(SampleInterface::class); - $this->assertEquals($instance->getPrimitive(), 'foo'); + $this->assertSame('foo', $instance->getPrimitive()); } public function testEnvironment() @@ -225,6 +240,19 @@ class FoundationApplicationTest extends TestCase $this->assertFalse($testing->isProduction()); } + public function testDebugHelper() + { + $debugOff = new Application; + $debugOff['config'] = new Repository(['app' => ['debug' => false]]); + + $this->assertFalse($debugOff->hasDebugModeEnabled()); + + $debugOn = new Application; + $debugOn['config'] = new Repository(['app' => ['debug' => true]]); + + $this->assertTrue($debugOn->hasDebugModeEnabled()); + } + public function testMethodAfterLoadingEnvironmentAddsClosure() { $app = new Application; @@ -363,7 +391,7 @@ class FoundationApplicationTest extends TestCase $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/services.php', $app->getCachedServicesPath()); $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/packages.php', $app->getCachedPackagesPath()); $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/config.php', $app->getCachedConfigPath()); - $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/routes.php', $app->getCachedRoutesPath()); + $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/routes-v7.php', $app->getCachedRoutesPath()); $this->assertSame('/base/path'.$ds.'bootstrap'.$ds.'cache/events.php', $app->getCachedEventsPath()); } @@ -376,7 +404,6 @@ class FoundationApplicationTest extends TestCase $_SERVER['APP_ROUTES_CACHE'] = '/absolute/path/routes.php'; $_SERVER['APP_EVENTS_CACHE'] = '/absolute/path/events.php'; - $ds = DIRECTORY_SEPARATOR; $this->assertSame('/absolute/path/services.php', $app->getCachedServicesPath()); $this->assertSame('/absolute/path/packages.php', $app->getCachedPackagesPath()); $this->assertSame('/absolute/path/config.php', $app->getCachedConfigPath()); @@ -419,7 +446,7 @@ class FoundationApplicationTest extends TestCase public function testEnvPathsAreUsedAndMadeAbsoluteForCachePathsWhenSpecifiedAsRelativeWithNullBasePath() { - $app = new Application(); + $app = new Application; $_SERVER['APP_SERVICES_CACHE'] = 'relative/path/services.php'; $_SERVER['APP_PACKAGES_CACHE'] = 'relative/path/packages.php'; $_SERVER['APP_CONFIG_CACHE'] = 'relative/path/config.php'; @@ -441,6 +468,31 @@ class FoundationApplicationTest extends TestCase $_SERVER['APP_EVENTS_CACHE'] ); } + + public function testEnvPathsAreAbsoluteInWindows() + { + $app = new Application(__DIR__); + $app->addAbsoluteCachePathPrefix('C:'); + $_SERVER['APP_SERVICES_CACHE'] = 'C:\framework\services.php'; + $_SERVER['APP_PACKAGES_CACHE'] = 'C:\framework\packages.php'; + $_SERVER['APP_CONFIG_CACHE'] = 'C:\framework\config.php'; + $_SERVER['APP_ROUTES_CACHE'] = 'C:\framework\routes.php'; + $_SERVER['APP_EVENTS_CACHE'] = 'C:\framework\events.php'; + + $this->assertSame('C:\framework\services.php', $app->getCachedServicesPath()); + $this->assertSame('C:\framework\packages.php', $app->getCachedPackagesPath()); + $this->assertSame('C:\framework\config.php', $app->getCachedConfigPath()); + $this->assertSame('C:\framework\routes.php', $app->getCachedRoutesPath()); + $this->assertSame('C:\framework\events.php', $app->getCachedEventsPath()); + + unset( + $_SERVER['APP_SERVICES_CACHE'], + $_SERVER['APP_PACKAGES_CACHE'], + $_SERVER['APP_CONFIG_CACHE'], + $_SERVER['APP_ROUTES_CACHE'], + $_SERVER['APP_EVENTS_CACHE'] + ); + } } class ApplicationBasicServiceProviderStub extends ServiceProvider diff --git a/tests/Foundation/FoundationAuthenticationTest.php b/tests/Foundation/FoundationAuthenticationTest.php index d6bc7523898286db492766397542033df11c0867..983907d72d379e010e99bbca355f508ee951ebd9 100644 --- a/tests/Foundation/FoundationAuthenticationTest.php +++ b/tests/Foundation/FoundationAuthenticationTest.php @@ -29,7 +29,7 @@ class FoundationAuthenticationTest extends TestCase ]; /** - * @var \Mockery + * @return \Illuminate\Contracts\Auth\Guard|\Mockery\LegacyMockInterface|\Mockery\MockInterface */ protected function mockGuard() { diff --git a/tests/Foundation/FoundationEnvironmentDetectorTest.php b/tests/Foundation/FoundationEnvironmentDetectorTest.php index db7f6a57048c7ae10572817b6fe133764f5af07d..d302c375bf50d6c40b0fd0277430c561459bcf7d 100644 --- a/tests/Foundation/FoundationEnvironmentDetectorTest.php +++ b/tests/Foundation/FoundationEnvironmentDetectorTest.php @@ -3,16 +3,10 @@ namespace Illuminate\Tests\Foundation; use Illuminate\Foundation\EnvironmentDetector; -use Mockery as m; use PHPUnit\Framework\TestCase; class FoundationEnvironmentDetectorTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testClosureCanBeUsedForCustomEnvironmentDetection() { $env = new EnvironmentDetector; diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 777a9fc3346ffc8d87dc0bacf866ed390647dc45..90f701ece326db6ad3f5bfa5980b319a2372c7ac 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -8,6 +8,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Routing\ResponseFactory as ResponseFactoryContract; use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\View\Factory; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Foundation\Exceptions\Handler; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -16,6 +17,7 @@ use Illuminate\Routing\ResponseFactory; use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -28,6 +30,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException; class FoundationExceptionsHandlerTest extends TestCase { + use MockeryPHPUnitIntegration; + protected $config; protected $container; @@ -60,8 +64,6 @@ class FoundationExceptionsHandlerTest extends TestCase protected function tearDown(): void { - m::close(); - Container::setInstance(null); } @@ -69,20 +71,56 @@ class FoundationExceptionsHandlerTest extends TestCase { $logger = m::mock(LoggerInterface::class); $this->container->instance(LoggerInterface::class, $logger); - $logger->shouldReceive('error')->withArgs(['Exception message', m::hasKey('exception')]); + $logger->shouldReceive('error')->withArgs(['Exception message', m::hasKey('exception')])->once(); $this->handler->report(new RuntimeException('Exception message')); } + public function testHandlerCallsContextMethodIfPresent() + { + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldReceive('error')->withArgs(['Exception message', m::subset(['foo' => 'bar'])])->once(); + + $this->handler->report(new ContextProvidingException('Exception message')); + } + + public function testHandlerReportsExceptionWhenUnReportable() + { + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldReceive('error')->withArgs(['Exception message', m::hasKey('exception')])->once(); + + $this->handler->report(new UnReportableException('Exception message')); + } + public function testHandlerCallsReportMethodWithDependencies() { $reporter = m::mock(ReportingService::class); $this->container->instance(ReportingService::class, $reporter); - $reporter->shouldReceive('send')->withArgs(['Exception message']); + $reporter->shouldReceive('send')->withArgs(['Exception message'])->once(); + + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldNotReceive('error'); $this->handler->report(new ReportableException('Exception message')); } + public function testHandlerReportsExceptionUsingCallableClass() + { + $reporter = m::mock(ReportingService::class); + $reporter->shouldReceive('send')->withArgs(['Exception message'])->once(); + + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldNotReceive('error'); + + $this->handler->reportable(new CustomReporter($reporter)); + + $this->handler->report(new CustomException('Exception message')); + } + public function testReturnsJsonWithStackTraceWhenAjaxRequestAndDebugTrue() { $this->config->shouldReceive('get')->with('app.debug', null)->once()->andReturn(true); @@ -97,13 +135,35 @@ class FoundationExceptionsHandlerTest extends TestCase $this->assertStringContainsString('"trace":', $response); } - public function testReturnsCustomResponseWhenExceptionImplementsResponsable() + public function testReturnsCustomResponseFromRenderableCallback() { + $this->handler->renderable(function (CustomException $e, $request) { + $this->assertSame($this->request, $request); + + return response()->json(['response' => 'My custom exception response']); + }); + $response = $this->handler->render($this->request, new CustomException)->getContent(); $this->assertSame('{"response":"My custom exception response"}', $response); } + public function testReturnsCustomResponseFromCallableClass() + { + $this->handler->renderable(new CustomRenderer); + + $response = $this->handler->render($this->request, new CustomException)->getContent(); + + $this->assertSame('{"response":"The CustomRenderer response"}', $response); + } + + public function testReturnsCustomResponseWhenExceptionImplementsResponsable() + { + $response = $this->handler->render($this->request, new ResponsableException)->getContent(); + + $this->assertSame('{"response":"My responsable exception response"}', $response); + } + public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndExceptionMessageIsMasked() { $this->config->shouldReceive('get')->with('app.debug', null)->once()->andReturn(false); @@ -208,13 +268,34 @@ class FoundationExceptionsHandlerTest extends TestCase $this->handler->report(new SuspiciousOperationException('Invalid method override "__CONSTRUCT"')); } + + public function testRecordsNotFoundReturns404WithoutReporting() + { + $this->config->shouldReceive('get')->with('app.debug', null)->once()->andReturn(true); + $this->request->shouldReceive('expectsJson')->once()->andReturn(true); + + $response = $this->handler->render($this->request, new RecordsNotFoundException); + + $this->assertEquals(404, $response->getStatusCode()); + $this->assertStringContainsString('"message": "Not found."', $response->getContent()); + + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldNotReceive('error'); + + $this->handler->report(new RecordsNotFoundException); + } +} + +class CustomException extends Exception +{ } -class CustomException extends Exception implements Responsable +class ResponsableException extends Exception implements Responsable { public function toResponse($request) { - return response()->json(['response' => 'My custom exception response']); + return response()->json(['response' => 'My responsable exception response']); } } @@ -226,6 +307,49 @@ class ReportableException extends Exception } } +class UnReportableException extends Exception +{ + public function report() + { + return false; + } +} + +class ContextProvidingException extends Exception +{ + public function context() + { + return [ + 'foo' => 'bar', + ]; + } +} + +class CustomReporter +{ + private $service; + + public function __construct(ReportingService $service) + { + $this->service = $service; + } + + public function __invoke(CustomException $e) + { + $this->service->send($e->getMessage()); + + return false; + } +} + +class CustomRenderer +{ + public function __invoke(CustomException $e, $request) + { + return response()->json(['response' => 'The CustomRenderer response']); + } +} + interface ReportingService { public function send($message); diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php index 7e621e63c4495b1c9a7482418770fc1cdf799484..9f2456583bc7446c1eb67e0a712871585c7688b3 100644 --- a/tests/Foundation/FoundationFormRequestTest.php +++ b/tests/Foundation/FoundationFormRequestTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Foundation; use Exception; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\Access\Response; use Illuminate\Container\Container; use Illuminate\Contracts\Translation\Translator; use Illuminate\Contracts\Validation\Factory as ValidationFactoryContract; @@ -101,6 +102,19 @@ class FoundationFormRequestTest extends TestCase $this->createRequest([], FoundationTestFormRequestForbiddenStub::class)->validateResolved(); } + public function testValidateThrowsExceptionFromAuthorizationResponse() + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('foo'); + + $this->createRequest([], FoundationTestFormRequestForbiddenWithResponseStub::class)->validateResolved(); + } + + public function testValidateDoesntThrowExceptionFromResponseAllowed() + { + $this->createRequest([], FoundationTestFormRequestPassesWithResponseStub::class)->validateResolved(); + } + public function testPrepareForValidationRunsBeforeValidation() { $this->createRequest([], FoundationTestFormRequestHooks::class)->validateResolved(); @@ -119,13 +133,15 @@ class FoundationFormRequestTest extends TestCase * Catch the given exception thrown from the executor, and return it. * * @param string $class - * @param \Closure $excecutor + * @param \Closure $executor * @return \Exception + * + * @throws \Exception */ - protected function catchException($class, $excecutor) + protected function catchException($class, $executor) { try { - $excecutor(); + $executor(); } catch (Exception $e) { if (is_a($e, $class)) { return $e; @@ -134,7 +150,7 @@ class FoundationFormRequestTest extends TestCase throw $e; } - throw new Exception("No exception thrown. Expected exception {$class}"); + throw new Exception("No exception thrown. Expected exception {$class}."); } /** @@ -320,3 +336,24 @@ class FoundationTestFormRequestHooks extends FormRequest $this->replace(['name' => 'Adam']); } } + +class FoundationTestFormRequestForbiddenWithResponseStub extends FormRequest +{ + public function authorize() + { + return Response::deny('foo'); + } +} + +class FoundationTestFormRequestPassesWithResponseStub extends FormRequest +{ + public function rules() + { + return []; + } + + public function authorize() + { + return Response::allow('baz'); + } +} diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index e18036601066d1fad69d36d14c6be5a6d8a76251..79fff922c4a577c298788756ca2ac76e19793ba0 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -43,26 +43,12 @@ class FoundationHelpersTest extends TestCase $this->assertSame('default', cache('baz', 'default')); } - public function testUnversionedElixir() - { - $file = 'unversioned.css'; - - app()->singleton('path.public', function () { - return __DIR__; - }); - - touch(public_path($file)); - - $this->assertSame('/'.$file, elixir($file)); - - unlink(public_path($file)); - } - public function testMixDoesNotIncludeHost() { $app = new Application; $app['config'] = m::mock(Repository::class); $app['config']->shouldReceive('get')->with('app.mix_url'); + $app['config']->shouldReceive('get')->with('app.mix_hot_proxy_url'); $manifest = $this->makeManifest(); @@ -78,6 +64,7 @@ class FoundationHelpersTest extends TestCase $app = new Application; $app['config'] = m::mock(Repository::class); $app['config']->shouldReceive('get')->with('app.mix_url'); + $app['config']->shouldReceive('get')->with('app.mix_hot_proxy_url'); $manifest = $this->makeManifest(); mix('unversioned.css'); @@ -93,6 +80,7 @@ class FoundationHelpersTest extends TestCase $app = new Application; $app['config'] = m::mock(Repository::class); $app['config']->shouldReceive('get')->with('app.mix_url'); + $app['config']->shouldReceive('get')->with('app.mix_hot_proxy_url'); $manifest = $this->makeManifest(); @@ -116,6 +104,7 @@ class FoundationHelpersTest extends TestCase $app = new Application; $app['config'] = m::mock(Repository::class); $app['config']->shouldReceive('get')->with('app.mix_url'); + $app['config']->shouldReceive('get')->with('app.mix_hot_proxy_url'); mkdir($directory = __DIR__.'/mix'); $manifest = $this->makeManifest('mix'); diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index caae5edf7bb7c8b4c8c8d998f19806a20d1028fe..c29074849bbd8ade03297dafec15057e8899412b 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -41,6 +41,14 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->assertDatabaseHas($this->table, $this->data); } + public function testAssertDatabaseHasSupportModels() + { + $this->mockCountBuilder(1); + + $this->assertDatabaseHas(ProductStub::class, $this->data); + $this->assertDatabaseHas(new ProductStub, $this->data); + } + public function testSeeInDatabaseDoesNotFindResults() { $this->expectException(ExpectationFailedException::class); @@ -91,6 +99,14 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->assertDatabaseMissing($this->table, $this->data); } + public function testAssertDatabaseMissingSupportModels() + { + $this->mockCountBuilder(0); + + $this->assertDatabaseMissing(ProductStub::class, $this->data); + $this->assertDatabaseMissing(new ProductStub, $this->data); + } + public function testDontSeeInDatabaseFindsResults() { $this->expectException(ExpectationFailedException::class); @@ -103,6 +119,30 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->assertDatabaseMissing($this->table, $this->data); } + public function testAssertTableEntriesCount() + { + $this->mockCountBuilder(1); + + $this->assertDatabaseCount($this->table, 1); + } + + public function testAssertDatabaseCountSupportModels() + { + $this->mockCountBuilder(1); + + $this->assertDatabaseCount(ProductStub::class, 1); + $this->assertDatabaseCount(new ProductStub, 1); + } + + public function testAssertTableEntriesCountWrong() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that table [products] matches expected entries count of 3. Entries found: 1.'); + $this->mockCountBuilder(1); + + $this->assertDatabaseCount($this->table, 3); + } + public function testAssertDeletedPassesWhenDoesNotFindResults() { $this->mockCountBuilder(0); @@ -132,6 +172,17 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->assertDeleted(new ProductStub($this->data)); } + public function testAssertModelMissingPassesWhenDoesNotFindModelResults() + { + $this->data = ['id' => 1]; + + $builder = $this->mockCountBuilder(0); + + $builder->shouldReceive('get')->andReturn(collect()); + + $this->assertModelMissing(new ProductStub($this->data)); + } + public function testAssertDeletedFailsWhenFindsModelResults() { $this->expectException(ExpectationFailedException::class); @@ -152,6 +203,13 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->assertSoftDeleted($this->table, $this->data); } + public function testAssertSoftDeletedSupportModelStrings() + { + $this->mockCountBuilder(1); + + $this->assertSoftDeleted(ProductStub::class, $this->data); + } + public function testAssertSoftDeletedInDatabaseDoesNotFindResults() { $this->expectException(ExpectationFailedException::class); @@ -183,13 +241,99 @@ class FoundationInteractsWithDatabaseTest extends TestCase $this->expectException(ExpectationFailedException::class); $this->expectExceptionMessage('The table is empty.'); + $model = new CustomProductStub(['id' => 1, 'name' => 'Laravel']); + $this->data = ['id' => 1, 'name' => 'Tailwind']; + + $builder = $this->mockCountBuilder(0, 'trashed_at'); + + $builder->shouldReceive('get')->andReturn(collect()); + + $this->assertSoftDeleted($model, ['name' => 'Tailwind']); + } + + public function testAssertNotSoftDeletedInDatabaseFindsResults() + { + $this->mockCountBuilder(1); + + $this->assertNotSoftDeleted($this->table, $this->data); + } + + public function testAssertNotSoftDeletedSupportModelStrings() + { + $this->mockCountBuilder(1); + + $this->assertNotSoftDeleted(ProductStub::class, $this->data); + } + + public function testAssertNotSoftDeletedOnlyFindsMatchingModels() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that any existing row'); + + $builder = $this->mockCountBuilder(0); + + $builder->shouldReceive('get')->andReturn(collect(), collect(1)); + + $this->assertNotSoftDeleted(ProductStub::class, $this->data); + } + + public function testAssertNotSoftDeletedInDatabaseDoesNotFindResults() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The table is empty.'); + + $builder = $this->mockCountBuilder(0); + + $builder->shouldReceive('get')->andReturn(collect()); + + $this->assertNotSoftDeleted($this->table, $this->data); + } + + public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelResults() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The table is empty.'); + $this->data = ['id' => 1]; + $builder = $this->mockCountBuilder(0); + + $builder->shouldReceive('get')->andReturn(collect()); + + $this->assertNotSoftDeleted(new ProductStub($this->data)); + } + + public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelWithCustomColumnResults() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The table is empty.'); + + $model = new CustomProductStub(['id' => 1, 'name' => 'Laravel']); + $this->data = ['id' => 1, 'name' => 'Tailwind']; + $builder = $this->mockCountBuilder(0, 'trashed_at'); $builder->shouldReceive('get')->andReturn(collect()); - $this->assertSoftDeleted(new CustomProductStub($this->data)); + $this->assertNotSoftDeleted($model, ['name' => 'Tailwind']); + } + + public function testAssertExistsPassesWhenFindsResults() + { + $this->data = ['id' => 1]; + + $builder = $this->mockCountBuilder(1); + + $builder->shouldReceive('get')->andReturn(collect($this->data)); + + $this->assertModelExists(new ProductStub($this->data)); + } + + public function testGetTableNameFromModel() + { + $this->assertEquals($this->table, $this->getTable(ProductStub::class)); + $this->assertEquals($this->table, $this->getTable(new ProductStub)); + $this->assertEquals($this->table, $this->getTable($this->table)); } protected function mockCountBuilder($countResult, $deletedAtColumn = 'deleted_at') @@ -207,6 +351,8 @@ class FoundationInteractsWithDatabaseTest extends TestCase $builder->shouldReceive('whereNotNull')->with($deletedAtColumn)->andReturnSelf(); + $builder->shouldReceive('whereNull')->with($deletedAtColumn)->andReturnSelf(); + $builder->shouldReceive('count')->andReturn($countResult)->byDefault(); $this->connection->shouldReceive('table') diff --git a/tests/Foundation/FoundationProviderRepositoryTest.php b/tests/Foundation/FoundationProviderRepositoryTest.php index c6c0095028b6646518fde53ab46b44ed2308b9d9..7177399a40e57e9214babb61ed80c73270d6803d 100755 --- a/tests/Foundation/FoundationProviderRepositoryTest.php +++ b/tests/Foundation/FoundationProviderRepositoryTest.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\ProviderRepository; use Illuminate\Support\ServiceProvider; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; class FoundationProviderRepositoryTest extends TestCase { @@ -92,12 +93,7 @@ class FoundationProviderRepositoryTest extends TestCase public function testWriteManifestThrowsExceptionIfManifestDirDoesntExist() { $this->expectException(Exception::class); - - if (is_callable([$this, 'expectExceptionMessageMatches'])) { - $this->expectExceptionMessageMatches('/^The (.*) directory must be present and writable.$/'); - } else { - $this->expectExceptionMessageRegExp('/^The (.*) directory must be present and writable.$/'); - } + $this->expectExceptionMessageMatches('/^The (.*) directory must be present and writable.$/'); $repo = new ProviderRepository(m::mock(ApplicationContract::class), $files = m::mock(Filesystem::class), __DIR__.'/cache/services.php'); $files->shouldReceive('replace')->never(); diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 1e25bb7051ad4004cd7bcea4575f4095b3c6c54f..1bac5bfc816a433685426aa7320be6a6f9efb75c 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Foundation\Http; +namespace Illuminate\Tests\Foundation\Bootstrap\Http; use Illuminate\Events\Dispatcher; use Illuminate\Foundation\Application; @@ -24,6 +24,23 @@ class KernelTest extends TestCase $this->assertEquals([], $kernel->getRouteMiddleware()); } + public function testGetMiddlewarePriority() + { + $kernel = new Kernel($this->getApplication(), $this->getRouter()); + + $this->assertEquals([ + \Illuminate\Cookie\Middleware\EncryptCookies::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, + \Illuminate\Routing\Middleware\ThrottleRequests::class, + \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + \Illuminate\Auth\Middleware\Authorize::class, + ], $kernel->getMiddlewarePriority()); + } + /** * @return \Illuminate\Contracts\Foundation\Application */ diff --git a/tests/Foundation/Http/Middleware/CheckForMaintenanceModeTest.php b/tests/Foundation/Http/Middleware/CheckForMaintenanceModeTest.php deleted file mode 100644 index c05b5e0fa200acdebd0f73c6f70b71c9db69c5d3..0000000000000000000000000000000000000000 --- a/tests/Foundation/Http/Middleware/CheckForMaintenanceModeTest.php +++ /dev/null @@ -1,175 +0,0 @@ -<?php - -namespace Illuminate\Tests\Foundation\Http\Middleware; - -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Filesystem\Filesystem; -use Illuminate\Foundation\Http\Exceptions\MaintenanceModeException; -use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; -use Illuminate\Http\Request; -use Mockery as m; -use PHPUnit\Framework\TestCase; - -class CheckForMaintenanceModeTest extends TestCase -{ - /** - * @var string - */ - protected $storagePath; - - /** - * @var string - */ - protected $downFilePath; - - /** - * @var \Illuminate\Filesystem\Filesystem - */ - protected $files; - - protected function setUp(): void - { - if (is_null($this->files)) { - $this->files = new Filesystem; - } - - $this->storagePath = __DIR__.'/tmp'; - $this->downFilePath = $this->storagePath.'/framework/down'; - - $this->files->makeDirectory($this->storagePath.'/framework', 0755, true); - } - - protected function tearDown(): void - { - $this->files->deleteDirectory($this->storagePath); - - m::close(); - } - - public function testApplicationIsRunningNormally() - { - $app = m::mock(Application::class); - $app->shouldReceive('isDownForMaintenance')->once()->andReturn(false); - - $middleware = new CheckForMaintenanceMode($app); - - $result = $middleware->handle(Request::create('/'), function ($request) { - return 'Running normally.'; - }); - - $this->assertSame('Running normally.', $result); - } - - public function testApplicationAllowsSomeIPs() - { - $ips = ['127.0.0.1', '2001:0db8:85a3:0000:0000:8a2e:0370:7334']; - - // Check IPv4. - $middleware = new CheckForMaintenanceMode($this->createMaintenanceApplication($ips)); - - $request = m::mock(Request::class); - $request->shouldReceive('ip')->once()->andReturn('127.0.0.1'); - - $result = $middleware->handle($request, function ($request) { - return 'Allowing [127.0.0.1]'; - }); - - $this->assertSame('Allowing [127.0.0.1]', $result); - - // Check IPv6. - $middleware = new CheckForMaintenanceMode($this->createMaintenanceApplication($ips)); - - $request = m::mock(Request::class); - $request->shouldReceive('ip')->once()->andReturn('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); - - $result = $middleware->handle($request, function ($request) { - return 'Allowing [2001:0db8:85a3:0000:0000:8a2e:0370:7334]'; - }); - - $this->assertSame('Allowing [2001:0db8:85a3:0000:0000:8a2e:0370:7334]', $result); - } - - public function testApplicationDeniesSomeIPs() - { - $this->expectException(MaintenanceModeException::class); - $this->expectExceptionMessage('This application is down for maintenance.'); - - $middleware = new CheckForMaintenanceMode($this->createMaintenanceApplication()); - - $middleware->handle(Request::create('/'), function ($request) { - // - }); - } - - public function testApplicationAllowsSomeURIs() - { - $app = $this->createMaintenanceApplication(); - - $middleware = new class($app) extends CheckForMaintenanceMode { - public function __construct($app) - { - parent::__construct($app); - - $this->except = ['foo/bar']; - } - }; - - $result = $middleware->handle(Request::create('/foo/bar'), function ($request) { - return 'Excepting /foo/bar'; - }); - - $this->assertSame('Excepting /foo/bar', $result); - } - - public function testApplicationDeniesSomeURIs() - { - $this->expectException(MaintenanceModeException::class); - $this->expectExceptionMessage('This application is down for maintenance.'); - - $middleware = new CheckForMaintenanceMode($this->createMaintenanceApplication()); - - $middleware->handle(Request::create('/foo/bar'), function ($request) { - // - }); - } - - /** - * Create a mock of maintenance application. - * - * @param string|array $ips - * @return \Mockery\MockInterface - */ - protected function createMaintenanceApplication($ips = null) - { - $this->makeDownFile($ips); - - $app = m::mock(Application::class); - $app->shouldReceive('isDownForMaintenance')->once()->andReturn(true); - $app->shouldReceive('storagePath')->once()->andReturn($this->storagePath); - - return $app; - } - - /** - * Make a down file with the given allowed ips. - * - * @param string|array $ips - * @return array - */ - protected function makeDownFile($ips = null) - { - $data = [ - 'time' => time(), - 'retry' => 86400, - 'message' => 'This application is down for maintenance.', - ]; - - if ($ips !== null) { - $data['allowed'] = $ips; - } - - $this->files->put($this->downFilePath, json_encode($data, JSON_PRETTY_PRINT)); - - return $data; - } -} diff --git a/tests/Foundation/Http/Middleware/ConvertEmptyStringsToNullTest.php b/tests/Foundation/Http/Middleware/ConvertEmptyStringsToNullTest.php new file mode 100644 index 0000000000000000000000000000000000000000..08c0c1d48e4833d380630d077e6cc4daa19767e3 --- /dev/null +++ b/tests/Foundation/Http/Middleware/ConvertEmptyStringsToNullTest.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Tests\Foundation\Bootstrap\Http\Middleware; + +use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Http\Request; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request as SymfonyRequest; + +class ConvertEmptyStringsToNullTest extends TestCase +{ + public function testConvertsEmptyStringsToNull() + { + $middleware = new ConvertEmptyStringsToNull; + $symfonyRequest = new SymfonyRequest([ + 'foo' => 'bar', + 'baz' => '', + ]); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, function (Request $request) { + $this->assertSame('bar', $request->get('foo')); + $this->assertNull($request->get('bar')); + }); + } +} diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index 2ffffa01624d594645dd4258533c2881f6ad9659..2bce0f5fe4c8bc175a49c888ca5381cb695e1287 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Foundation\Http\Middleware; +namespace Illuminate\Tests\Foundation\Bootstrap\Http\Middleware; use Illuminate\Foundation\Http\Middleware\TransformsRequest; use Illuminate\Http\Request; diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index 1561eab6b03c56fa580f1bcd3ba3a8a82869c0c7..8017574e4b49cbf397a0efb71f0d4fef10b63b89 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Foundation\Http\Middleware; +namespace Illuminate\Tests\Foundation\Bootstrap\Http\Middleware; use Illuminate\Foundation\Http\Middleware\TrimStrings; use Illuminate\Http\Request; @@ -11,7 +11,7 @@ class TrimStringsTest extends TestCase { public function testTrimStringsIgnoringExceptAttribute() { - $middleware = new TrimStringsWithExceptAttribute(); + $middleware = new TrimStringsWithExceptAttribute; $symfonyRequest = new SymfonyRequest([ 'abc' => ' 123 ', 'xyz' => ' 456 ', diff --git a/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php b/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php index cfe4460ad07d5380b9c8c90faefb186843b314f4..dddc721de22ece60e752608a826774b136337c56 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php @@ -1,9 +1,10 @@ <?php -namespace Illuminate\Tests\Foundation\Testing\Concerns; +namespace Illuminate\Tests\Foundation\Bootstrap\Testing\Concerns; use Illuminate\Foundation\Mix; use Orchestra\Testbench\TestCase; +use stdClass; class InteractsWithContainerTest extends TestCase { @@ -17,7 +18,7 @@ class InteractsWithContainerTest extends TestCase public function testWithMixRestoresOriginalHandlerAndReturnsInstance() { - $handler = new \stdClass(); + $handler = new stdClass; $this->app->instance(Mix::class, $handler); $this->withoutMix(); @@ -26,4 +27,25 @@ class InteractsWithContainerTest extends TestCase $this->assertSame($handler, resolve(Mix::class)); $this->assertSame($this, $instance); } + + public function testForgetMock() + { + $this->mock(IntanceStub::class) + ->shouldReceive('execute') + ->once() + ->andReturn('bar'); + + $this->assertSame('bar', $this->app->make(IntanceStub::class)->execute()); + + $this->forgetMock(IntanceStub::class); + $this->assertSame('foo', $this->app->make(IntanceStub::class)->execute()); + } +} + +class IntanceStub +{ + public function execute() + { + return 'foo'; + } } diff --git a/tests/Foundation/Testing/Concerns/InteractsWithViewsTest.php b/tests/Foundation/Testing/Concerns/InteractsWithViewsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c7b9f22d5346c99281d245c97db61f5447e4066b --- /dev/null +++ b/tests/Foundation/Testing/Concerns/InteractsWithViewsTest.php @@ -0,0 +1,43 @@ +<?php + +namespace Illuminate\Tests\Foundation\Bootstrap\Testing\Concerns; + +use Illuminate\Foundation\Testing\Concerns\InteractsWithViews; +use Illuminate\View\Component; +use Orchestra\Testbench\TestCase; + +class InteractsWithViewsTest extends TestCase +{ + use InteractsWithViews; + + public function testBladeCorrectlyRendersString() + { + $string = (string) $this->blade('@if(true)test @endif'); + + $this->assertEquals('test ', $string); + } + + public function testComponentCanAccessPublicProperties() + { + $exampleComponent = new class extends Component + { + public $foo = 'bar'; + + public function speak() + { + return 'hello'; + } + + public function render() + { + return 'rendered content'; + } + }; + + $component = $this->component(get_class($exampleComponent)); + + $this->assertEquals('bar', $component->foo); + $this->assertEquals('hello', $component->speak()); + $component->assertSee('content'); + } +} diff --git a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php index f1f696832ab143fe8238ad792804b12b39e235b5..54e1e370a411967e2b7471feec094ff99619f428 100644 --- a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php +++ b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php @@ -1,7 +1,10 @@ <?php -namespace Illuminate\Tests\Foundation\Testing\Concerns; +namespace Illuminate\Tests\Foundation\Bootstrap\Testing\Concerns; +use Illuminate\Contracts\Routing\Registrar; +use Illuminate\Contracts\Routing\UrlGenerator; +use Illuminate\Http\RedirectResponse; use Orchestra\Testbench\TestCase; class MakesHttpRequestsTest extends TestCase @@ -14,6 +17,15 @@ class MakesHttpRequestsTest extends TestCase $this->assertSame('previous/url', $this->app['session']->previousUrl()); } + public function testWithTokenSetsAuthorizationHeader() + { + $this->withToken('foobar'); + $this->assertSame('Bearer foobar', $this->defaultHeaders['Authorization']); + + $this->withToken('foobar', 'Basic'); + $this->assertSame('Basic foobar', $this->defaultHeaders['Authorization']); + } + public function testWithoutAndWithMiddleware() { $this->assertFalse($this->app->has('middleware.disable')); @@ -94,6 +106,59 @@ class MakesHttpRequestsTest extends TestCase $this->assertSame('baz', $this->unencryptedCookies['foo']); $this->assertSame('new-value', $this->unencryptedCookies['new-cookie']); } + + public function testWithoutAndWithCredentials() + { + $this->encryptCookies = false; + + $this->assertSame([], $this->prepareCookiesForJsonRequest()); + + $this->withCredentials(); + $this->defaultCookies = ['foo' => 'bar']; + $this->assertSame(['foo' => 'bar'], $this->prepareCookiesForJsonRequest()); + } + + public function testFollowingRedirects() + { + $router = $this->app->make(Registrar::class); + $url = $this->app->make(UrlGenerator::class); + + $router->get('from', function () use ($url) { + return new RedirectResponse($url->to('to')); + }); + + $router->get('to', function () { + return 'OK'; + }); + + $this->followingRedirects() + ->get('from') + ->assertOk() + ->assertSee('OK'); + } + + public function testFollowingRedirectsTerminatesInExpectedOrder() + { + $router = $this->app->make(Registrar::class); + $url = $this->app->make(UrlGenerator::class); + + $callOrder = []; + TerminatingMiddleware::$callback = function ($request) use (&$callOrder) { + $callOrder[] = $request->path(); + }; + + $router->get('from', function () use ($url) { + return new RedirectResponse($url->to('to')); + })->middleware(TerminatingMiddleware::class); + + $router->get('to', function () { + return 'OK'; + })->middleware(TerminatingMiddleware::class); + + $this->followingRedirects()->get('from'); + + $this->assertEquals(['from', 'to'], $callOrder); + } } class MyMiddleware @@ -103,3 +168,18 @@ class MyMiddleware return $next($request.'WithMiddleware'); } } + +class TerminatingMiddleware +{ + public static $callback; + + public function handle($request, $next) + { + return $next($request); + } + + public function terminate($request, $response) + { + call_user_func(static::$callback, $request, $response); + } +} diff --git a/tests/Foundation/Testing/DatabaseMigrationsTest.php b/tests/Foundation/Testing/DatabaseMigrationsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b2c24dbdcf525e8cf0eecd70514f32bd0f5930f5 --- /dev/null +++ b/tests/Foundation/Testing/DatabaseMigrationsTest.php @@ -0,0 +1,95 @@ +<?php + +namespace Illuminate\Tests\Foundation\Testing; + +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\DatabaseMigrations; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use PHPUnit\Framework\TestCase; + +class DatabaseMigrationsTest extends TestCase +{ + protected $traitObject; + + protected function setUp(): void + { + RefreshDatabaseState::$migrated = false; + + $this->traitObject = $this->getMockForTrait(DatabaseMigrations::class, [], '', true, true, true, [ + 'artisan', + 'beforeApplicationDestroyed', + ]); + + $kernelObj = \Mockery::mock(); + $kernelObj->shouldReceive('setArtisan') + ->with(null); + + $this->traitObject->app = [ + Kernel::class => $kernelObj, + ]; + } + + private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) + { + $migrateFreshUsingReflection = new \ReflectionMethod( + get_class($this->traitObject), + $methodName + ); + + $migrateFreshUsingReflection->setAccessible(true); + + return $migrateFreshUsingReflection; + } + + public function testRefreshTestDatabaseDefault() + { + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => false, + '--drop-types' => false, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('runDatabaseMigrations'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } + + public function testRefreshTestDatabaseWithDropViewsOption() + { + $this->traitObject->dropViews = true; + + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => true, + '--drop-types' => false, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('runDatabaseMigrations'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } + + public function testRefreshTestDatabaseWithDropTypesOption() + { + $this->traitObject->dropTypes = true; + + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => false, + '--drop-types' => true, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('runDatabaseMigrations'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } +} diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..84f3100c178837d2a4d3173f4ec97860ad6ec5ba --- /dev/null +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -0,0 +1,95 @@ +<?php + +namespace Illuminate\Tests\Foundation\Testing; + +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use PHPUnit\Framework\TestCase; + +class RefreshDatabaseTest extends TestCase +{ + protected $traitObject; + + protected function setUp(): void + { + RefreshDatabaseState::$migrated = false; + + $this->traitObject = $this->getMockForTrait(RefreshDatabase::class, [], '', true, true, true, [ + 'artisan', + 'beginDatabaseTransaction', + ]); + + $kernelObj = \Mockery::mock(); + $kernelObj->shouldReceive('setArtisan') + ->with(null); + + $this->traitObject->app = [ + Kernel::class => $kernelObj, + ]; + } + + private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) + { + $migrateFreshUsingReflection = new \ReflectionMethod( + get_class($this->traitObject), + $methodName + ); + + $migrateFreshUsingReflection->setAccessible(true); + + return $migrateFreshUsingReflection; + } + + public function testRefreshTestDatabaseDefault() + { + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => false, + '--drop-types' => false, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('refreshTestDatabase'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } + + public function testRefreshTestDatabaseWithDropViewsOption() + { + $this->traitObject->dropViews = true; + + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => true, + '--drop-types' => false, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('refreshTestDatabase'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } + + public function testRefreshTestDatabaseWithDropTypesOption() + { + $this->traitObject->dropTypes = true; + + $this->traitObject + ->expects($this->exactly(1)) + ->method('artisan') + ->with('migrate:fresh', [ + '--drop-views' => false, + '--drop-types' => true, + '--seed' => false, + ]); + + $refreshTestDatabaseReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('refreshTestDatabase'); + + $refreshTestDatabaseReflection->invoke($this->traitObject); + } +} diff --git a/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0e1120f0c86c8ccbf45dd3bae800648b94c0dd73 --- /dev/null +++ b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Illuminate\Tests\Foundation\Testing\Traits; + +use Illuminate\Foundation\Testing\Traits\CanConfigureMigrationCommands; +use PHPUnit\Framework\TestCase; + +class CanConfigureMigrationCommandsTest extends TestCase +{ + protected $traitObject; + + protected function setup(): void + { + $this->traitObject = $this->getObjectForTrait(CanConfigureMigrationCommands::class); + } + + private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) + { + $migrateFreshUsingReflection = new \ReflectionMethod( + get_class($this->traitObject), + $methodName + ); + + $migrateFreshUsingReflection->setAccessible(true); + + return $migrateFreshUsingReflection; + } + + public function testMigrateFreshUsingDefault(): void + { + $migrateFreshUsingReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('migrateFreshUsing'); + + $expected = [ + '--drop-views' => false, + '--drop-types' => false, + '--seed' => false, + ]; + + $this->assertEquals($expected, $migrateFreshUsingReflection->invoke($this->traitObject)); + } + + public function testMigrateFreshUsingWithPropertySets(): void + { + $migrateFreshUsingReflection = $this->__reflectAndSetupAccessibleForProtectedTraitMethod('migrateFreshUsing'); + + $expected = [ + '--drop-views' => true, + '--drop-types' => false, + '--seed' => false, + ]; + + $this->traitObject->dropViews = true; + + $this->assertEquals($expected, $migrateFreshUsingReflection->invoke($this->traitObject)); + + $expected = [ + '--drop-views' => false, + '--drop-types' => true, + '--seed' => false, + ]; + + $this->traitObject->dropViews = false; + $this->traitObject->dropTypes = true; + + $this->assertEquals($expected, $migrateFreshUsingReflection->invoke($this->traitObject)); + + $expected = [ + '--drop-views' => true, + '--drop-types' => true, + '--seed' => false, + ]; + + $this->traitObject->dropViews = true; + $this->traitObject->dropTypes = true; + + $this->assertEquals($expected, $migrateFreshUsingReflection->invoke($this->traitObject)); + } +} diff --git a/tests/Foundation/Testing/WormholeTest.php b/tests/Foundation/Testing/WormholeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ba95d278d53197eda2240761920a1ad54c7c5f09 --- /dev/null +++ b/tests/Foundation/Testing/WormholeTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Illuminate\Tests\Foundation\Bootstrap\Testing; + +use Carbon\CarbonImmutable; +use Illuminate\Foundation\Testing\Wormhole; +use Illuminate\Support\Facades\Date; +use PHPUnit\Framework\TestCase; + +class WormholeTest extends TestCase +{ + public function testCanTravelBackToPresent() + { + // Preserve the timelines we want to compare the reality with... + $present = now(); + $future = now()->addDays(10); + + // Travel in time... + (new Wormhole(10))->days(); + + // Assert we are now in the future... + $this->assertEquals($future->format('Y-m-d'), now()->format('Y-m-d')); + + // Assert we can go back to the present... + $this->assertEquals($present->format('Y-m-d'), Wormhole::back()->format('Y-m-d')); + } + + public function testCarbonImmutableCompatibility() + { + // Tell the Date Factory to use CarbonImmutable... + Date::use(CarbonImmutable::class); + + // Record what time it is in 10 days... + $present = now(); + $future = $present->addDays(10); + + // Travel in time... + (new Wormhole(10))->days(); + + // Assert that the present time didn't get mutated... + $this->assertNotEquals($future->format('Y-m-d'), $present->format('Y-m-d')); + + // Assert the time travel was successful... + $this->assertEquals($future->format('Y-m-d'), now()->format('Y-m-d')); + + // Restore the default Date Factory... + Date::useDefault(); + } +} diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php index 462372ca16c9abab54e06c907b37c23334fc299b..62e06e4ac8961e4f814db34da592d1e7bf0b148f 100755 --- a/tests/Hashing/HasherTest.php +++ b/tests/Hashing/HasherTest.php @@ -23,10 +23,6 @@ class HasherTest extends TestCase public function testBasicArgon2iHashing() { - if (! defined('PASSWORD_ARGON2I')) { - $this->markTestSkipped('PHP not compiled with Argon2i hashing support.'); - } - $hasher = new ArgonHasher; $value = $hasher->make('password'); $this->assertNotSame('password', $value); @@ -38,10 +34,6 @@ class HasherTest extends TestCase public function testBasicArgon2idHashing() { - if (! defined('PASSWORD_ARGON2ID')) { - $this->markTestSkipped('PHP not compiled with Argon2id hashing support.'); - } - $hasher = new Argon2IdHasher; $value = $hasher->make('password'); $this->assertNotSame('password', $value); @@ -58,10 +50,6 @@ class HasherTest extends TestCase { $this->expectException(RuntimeException::class); - if (! defined('PASSWORD_ARGON2I')) { - $this->markTestSkipped('PHP not compiled with Argon2i hashing support.'); - } - $argonHasher = new ArgonHasher(['verify' => true]); $argonHashed = $argonHasher->make('password'); (new BcryptHasher(['verify' => true]))->check('password', $argonHashed); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9101750f7de88d343a94b2a75d6c2a356f95b483 --- /dev/null +++ b/tests/Http/HttpClientTest.php @@ -0,0 +1,1105 @@ +<?php + +namespace Illuminate\Tests\Http; + +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response as Psr7Response; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Http\Client\Events\RequestSending; +use Illuminate\Http\Client\Events\ResponseReceived; +use Illuminate\Http\Client\Factory; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Pool; +use Illuminate\Http\Client\Request; +use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; +use Illuminate\Http\Client\ResponseSequence; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Mockery as m; +use OutOfBoundsException; +use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\TestCase; +use Symfony\Component\VarDumper\VarDumper; + +class HttpClientTest extends TestCase +{ + /** + * @var \Illuminate\Http\Client\Factory + */ + protected $factory; + + protected function setUp(): void + { + parent::setUp(); + + $this->factory = new Factory; + } + + protected function tearDown(): void + { + m::close(); + } + + public function testStubbedResponsesAreReturnedAfterFaking() + { + $this->factory->fake(); + + $response = $this->factory->post('http://laravel.com/test-missing-page'); + + $this->assertTrue($response->ok()); + } + + public function testUnauthorizedRequest() + { + $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 401), + ]); + + $response = $this->factory->post('http://laravel.com'); + + $this->assertTrue($response->unauthorized()); + } + + public function testForbiddenRequest() + { + $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 403), + ]); + + $response = $this->factory->post('http://laravel.com'); + + $this->assertTrue($response->forbidden()); + } + + public function testResponseBodyCasting() + { + $this->factory->fake([ + '*' => ['result' => ['foo' => 'bar']], + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertSame('{"result":{"foo":"bar"}}', $response->body()); + $this->assertSame('{"result":{"foo":"bar"}}', (string) $response); + $this->assertIsArray($response->json()); + $this->assertSame(['foo' => 'bar'], $response->json()['result']); + $this->assertSame(['foo' => 'bar'], $response->json('result')); + $this->assertSame('bar', $response->json('result.foo')); + $this->assertSame('default', $response->json('missing_key', 'default')); + $this->assertSame(['foo' => 'bar'], $response['result']); + $this->assertIsObject($response->object()); + $this->assertSame('bar', $response->object()->result->foo); + } + + public function testResponseCanBeReturnedAsCollection() + { + $this->factory->fake([ + '*' => ['result' => ['foo' => 'bar']], + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertInstanceOf(Collection::class, $response->collect()); + $this->assertEquals(collect(['result' => ['foo' => 'bar']]), $response->collect()); + $this->assertEquals(collect(['foo' => 'bar']), $response->collect('result')); + $this->assertEquals(collect(['bar']), $response->collect('result.foo')); + $this->assertEquals(collect(), $response->collect('missing_key')); + } + + public function testSendRequestBody() + { + $body = '{"test":"phpunit"}'; + + $fakeRequest = function (Request $request) use ($body) { + self::assertSame($body, $request->body()); + + return ['my' => 'response']; + }; + + $this->factory->fake($fakeRequest); + + $this->factory->withBody($body, 'application/json')->send('get', 'http://foo.com/api'); + } + + public function testUrlsCanBeStubbedByPath() + { + $this->factory->fake([ + 'foo.com/*' => ['page' => 'foo'], + 'bar.com/*' => ['page' => 'bar'], + '*' => ['page' => 'fallback'], + ]); + + $fooResponse = $this->factory->post('http://foo.com/test'); + $barResponse = $this->factory->post('http://bar.com/test'); + $fallbackResponse = $this->factory->post('http://fallback.com/test'); + + $this->assertSame('foo', $fooResponse['page']); + $this->assertSame('bar', $barResponse['page']); + $this->assertSame('fallback', $fallbackResponse['page']); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/test' && + $request->hasHeader('Content-Type', 'application/json'); + }); + } + + public function testCanSendJsonData() + { + $this->factory->fake(); + + $this->factory->withHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://foo.com/json', [ + 'name' => 'Taylor', + ]); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeader('Content-Type', 'application/json') && + $request->hasHeader('X-Test-Header', 'foo') && + $request->hasHeader('X-Test-ArrayHeader', ['bar', 'baz']) && + $request['name'] === 'Taylor'; + }); + } + + public function testCanSendFormData() + { + $this->factory->fake(); + + $this->factory->asForm()->post('http://foo.com/form', [ + 'name' => 'Taylor', + 'title' => 'Laravel Developer', + ]); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/form' && + $request->hasHeader('Content-Type', 'application/x-www-form-urlencoded') && + $request['name'] === 'Taylor'; + }); + } + + public function testRecordedCallsAreEmptiedWhenFakeIsCalled() + { + $this->factory->fake([ + 'http://foo.com/*' => ['page' => 'foo'], + ]); + + $this->factory->get('http://foo.com/test'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/test'; + }); + + $this->factory->fake(); + + $this->factory->assertNothingSent(); + } + + public function testSpecificRequestIsNotBeingSent() + { + $this->factory->fake(); + + $this->factory->post('http://foo.com/form', [ + 'name' => 'Taylor', + ]); + + $this->factory->assertNotSent(function (Request $request) { + return $request->url() === 'http://foo.com/form' && + $request['name'] === 'Peter'; + }); + } + + public function testNoRequestIsNotBeingSent() + { + $this->factory->fake(); + + $this->factory->assertNothingSent(); + } + + public function testRequestCount() + { + $this->factory->fake(); + $this->factory->assertSentCount(0); + + $this->factory->post('http://foo.com/form', [ + 'name' => 'Taylor', + ]); + + $this->factory->assertSentCount(1); + + $this->factory->post('http://foo.com/form', [ + 'name' => 'Jim', + ]); + + $this->factory->assertSentCount(2); + } + + public function testCanSendMultipartData() + { + $this->factory->fake(); + + $this->factory->asMultipart()->post('http://foo.com/multipart', [ + [ + 'name' => 'foo', + 'contents' => 'data', + 'headers' => ['X-Test-Header' => 'foo'], + ], + ]); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/multipart' && + Str::startsWith($request->header('Content-Type')[0], 'multipart') && + $request[0]['name'] === 'foo'; + }); + } + + public function testFilesCanBeAttached() + { + $this->factory->fake(); + + $this->factory->attach('foo', 'data', 'file.txt', ['X-Test-Header' => 'foo']) + ->post('http://foo.com/file'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/file' && + Str::startsWith($request->header('Content-Type')[0], 'multipart') && + $request[0]['name'] === 'foo' && + $request->hasFile('foo', 'data', 'file.txt'); + }); + } + + public function testCanSendMultipartDataWithSimplifiedParameters() + { + $this->factory->fake(); + + $this->factory->asMultipart()->post('http://foo.com/multipart', [ + 'foo' => 'bar', + ]); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/multipart' && + Str::startsWith($request->header('Content-Type')[0], 'multipart') && + $request[0]['name'] === 'foo' && + $request[0]['contents'] === 'bar'; + }); + } + + public function testCanSendMultipartDataWithBothSimplifiedAndExtendedParameters() + { + $this->factory->fake(); + + $this->factory->asMultipart()->post('http://foo.com/multipart', [ + 'foo' => 'bar', + [ + 'name' => 'foobar', + 'contents' => 'data', + 'headers' => ['X-Test-Header' => 'foo'], + ], + ]); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/multipart' && + Str::startsWith($request->header('Content-Type')[0], 'multipart') && + $request[0]['name'] === 'foo' && + $request[0]['contents'] === 'bar' && + $request[1]['name'] === 'foobar' && + $request[1]['contents'] === 'data' && + $request[1]['headers']['X-Test-Header'] === 'foo'; + }); + } + + public function testItCanSendToken() + { + $this->factory->fake(); + + $this->factory->withToken('token')->post('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeader('Authorization', 'Bearer token'); + }); + } + + public function testItCanSendUserAgent() + { + $this->factory->fake(); + + $this->factory->withUserAgent('Laravel')->post('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeader('User-Agent', 'Laravel'); + }); + } + + public function testItOnlySendsOneUserAgentHeader() + { + $this->factory->fake(); + + $this->factory->withUserAgent('Laravel') + ->withUserAgent('FooBar') + ->post('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + $userAgent = $request->header('User-Agent'); + + return $request->url() === 'http://foo.com/json' && + count($userAgent) === 1 && + $userAgent[0] === 'FooBar'; + }); + } + + public function testSequenceBuilder() + { + $this->factory->fake([ + '*' => $this->factory->sequence() + ->push('Ok', 201) + ->push(['fact' => 'Cats are great!']) + ->pushFile(__DIR__.'/fixtures/test.txt') + ->pushStatus(403), + ]); + + $response = $this->factory->get('https://example.com'); + $this->assertSame('Ok', $response->body()); + $this->assertSame(201, $response->status()); + + $response = $this->factory->get('https://example.com'); + $this->assertSame(['fact' => 'Cats are great!'], $response->json()); + $this->assertSame(200, $response->status()); + + $response = $this->factory->get('https://example.com'); + $this->assertSame("This is a story about something that happened long ago when your grandfather was a child.\n", $response->body()); + $this->assertSame(200, $response->status()); + + $response = $this->factory->get('https://example.com'); + $this->assertSame('', $response->body()); + $this->assertSame(403, $response->status()); + + $this->expectException(OutOfBoundsException::class); + + // The sequence is empty, it should throw an exception. + $this->factory->get('https://example.com'); + } + + public function testSequenceBuilderCanKeepGoingWhenEmpty() + { + $this->factory->fake([ + '*' => $this->factory->sequence() + ->dontFailWhenEmpty() + ->push('Ok'), + ]); + + $response = $this->factory->get('https://laravel.com'); + $this->assertSame('Ok', $response->body()); + + // The sequence is empty, but it should not fail. + $this->factory->get('https://laravel.com'); + } + + public function testAssertSequencesAreEmpty() + { + $this->factory->fake([ + '*' => $this->factory->sequence() + ->push('1') + ->push('2'), + ]); + + $this->factory->get('https://example.com'); + $this->factory->get('https://example.com'); + + $this->factory->assertSequencesAreEmpty(); + } + + public function testFakeSequence() + { + $this->factory->fakeSequence() + ->pushStatus(201) + ->pushStatus(301); + + $this->assertSame(201, $this->factory->get('https://example.com')->status()); + $this->assertSame(301, $this->factory->get('https://example.com')->status()); + } + + public function testWithCookies() + { + $this->factory->fakeSequence()->pushStatus(200); + + $response = $this->factory->withCookies( + ['foo' => 'bar'], 'https://laravel.com' + )->get('https://laravel.com'); + + $this->assertCount(1, $response->cookies()->toArray()); + + /** @var \GuzzleHttp\Cookie\CookieJarInterface $responseCookies */ + $responseCookie = $response->cookies()->toArray()[0]; + + $this->assertSame('foo', $responseCookie['Name']); + $this->assertSame('bar', $responseCookie['Value']); + $this->assertSame('https://laravel.com', $responseCookie['Domain']); + } + + public function testGetWithArrayQueryParam() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get', ['foo' => 'bar']); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?foo=bar' + && $request['foo'] === 'bar'; + }); + } + + public function testGetWithStringQueryParam() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get', 'foo=bar'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?foo=bar' + && $request['foo'] === 'bar'; + }); + } + + public function testGetWithQuery() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get?foo=bar&page=1'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?foo=bar&page=1' + && $request['foo'] === 'bar' + && $request['page'] === '1'; + }); + } + + public function testGetWithQueryWontEncode() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get?foo;bar;1;5;10&page=1'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?foo;bar;1;5;10&page=1' + && ! isset($request['foo']) + && ! isset($request['bar']) + && $request['page'] === '1'; + }); + } + + public function testGetWithArrayQueryParamOverwrites() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get?foo=bar&page=1', ['hello' => 'world']); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?hello=world' + && $request['hello'] === 'world'; + }); + } + + public function testGetWithArrayQueryParamEncodes() + { + $this->factory->fake(); + + $this->factory->get('http://foo.com/get', ['foo;bar; space test' => 'laravel']); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/get?foo%3Bbar%3B%20space%20test=laravel' + && $request['foo;bar; space test'] === 'laravel'; + }); + } + + public function testCanConfirmManyHeaders() + { + $this->factory->fake(); + + $this->factory->withHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ]); + }); + } + + public function testCanConfirmManyHeadersUsingAString() + { + $this->factory->fake(); + + $this->factory->withHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeaders('X-Test-Header'); + }); + } + + public function testExceptionAccessorOnSuccess() + { + $resp = new Response(new Psr7Response()); + + $this->assertNull($resp->toException()); + } + + public function testExceptionAccessorOnFailure() + { + $error = [ + 'error' => [ + 'code' => 403, + 'message' => 'The Request can not be completed', + ], + ]; + $response = new Psr7Response(403, [], json_encode($error)); + $resp = new Response($response); + + $this->assertInstanceOf(RequestException::class, $resp->toException()); + } + + public function testRequestExceptionSummary() + { + $this->expectException(RequestException::class); + $this->expectExceptionMessage('{"error":{"code":403,"message":"The Request can not be completed"}}'); + + $error = [ + 'error' => [ + 'code' => 403, + 'message' => 'The Request can not be completed', + ], + ]; + $response = new Psr7Response(403, [], json_encode($error)); + + throw new RequestException(new Response($response)); + } + + public function testRequestExceptionTruncatedSummary() + { + $this->expectException(RequestException::class); + $this->expectExceptionMessage('{"error":{"code":403,"message":"The Request can not be completed because quota limit was exceeded. Please, check our sup (truncated...)'); + + $error = [ + 'error' => [ + 'code' => 403, + 'message' => 'The Request can not be completed because quota limit was exceeded. Please, check our support team to increase your limit', + ], + ]; + $response = new Psr7Response(403, [], json_encode($error)); + + throw new RequestException(new Response($response)); + } + + public function testRequestExceptionEmptyBody() + { + $this->expectException(RequestException::class); + $this->expectExceptionMessageMatches('/HTTP request returned status code 403$/'); + + $response = new Psr7Response(403); + + throw new RequestException(new Response($response)); + } + + public function testOnErrorDoesntCallClosureOnInformational() + { + $status = 0; + $client = $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 101), + ]); + + $response = $client->get('laravel.com') + ->onError(function ($response) use (&$status) { + $status = $response->status(); + }); + + $this->assertSame(0, $status); + $this->assertSame(101, $response->status()); + } + + public function testOnErrorDoesntCallClosureOnSuccess() + { + $status = 0; + $client = $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 201), + ]); + + $response = $client->get('laravel.com') + ->onError(function ($response) use (&$status) { + $status = $response->status(); + }); + + $this->assertSame(0, $status); + $this->assertSame(201, $response->status()); + } + + public function testOnErrorDoesntCallClosureOnRedirection() + { + $status = 0; + $client = $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 301), + ]); + + $response = $client->get('laravel.com') + ->onError(function ($response) use (&$status) { + $status = $response->status(); + }); + + $this->assertSame(0, $status); + $this->assertSame(301, $response->status()); + } + + public function testOnErrorCallsClosureOnClientError() + { + $status = 0; + $client = $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 401), + ]); + + $response = $client->get('laravel.com') + ->onError(function ($response) use (&$status) { + $status = $response->status(); + }); + + $this->assertSame(401, $status); + $this->assertSame(401, $response->status()); + } + + public function testOnErrorCallsClosureOnServerError() + { + $status = 0; + $client = $this->factory->fake([ + 'laravel.com' => $this->factory::response('', 501), + ]); + + $response = $client->get('laravel.com') + ->onError(function ($response) use (&$status) { + $status = $response->status(); + }); + + $this->assertSame(501, $status); + $this->assertSame(501, $response->status()); + } + + public function testSinkToFile() + { + $this->factory->fakeSequence()->push('abc123'); + + $destination = __DIR__.'/fixtures/sunk.txt'; + + if (file_exists($destination)) { + unlink($destination); + } + + $this->factory->withOptions(['sink' => $destination])->get('https://example.com'); + + $this->assertFileExists($destination); + $this->assertSame('abc123', file_get_contents($destination)); + + unlink($destination); + } + + public function testSinkToResource() + { + $this->factory->fakeSequence()->push('abc123'); + + $resource = fopen('php://temp', 'w'); + + $this->factory->sink($resource)->get('https://example.com'); + + $this->assertSame(0, ftell($resource)); + $this->assertSame('abc123', stream_get_contents($resource)); + } + + public function testSinkWhenStubbedByPath() + { + $this->factory->fake([ + 'foo.com/*' => ['page' => 'foo'], + ]); + + $resource = fopen('php://temp', 'w'); + + $this->factory->sink($resource)->get('http://foo.com/test'); + + $this->assertSame(json_encode(['page' => 'foo']), stream_get_contents($resource)); + } + + public function testCanAssertAgainstOrderOfHttpRequestsWithUrlStrings() + { + $this->factory->fake(); + + $exampleUrls = [ + 'http://example.com/1', + 'http://example.com/2', + 'http://example.com/3', + ]; + + foreach ($exampleUrls as $url) { + $this->factory->get($url); + } + + $this->factory->assertSentInOrder($exampleUrls); + } + + public function testAssertionsSentOutOfOrderThrowAssertionFailed() + { + $this->factory->fake(); + + $exampleUrls = [ + 'http://example.com/1', + 'http://example.com/2', + 'http://example.com/3', + ]; + + $this->factory->get($exampleUrls[0]); + $this->factory->get($exampleUrls[2]); + $this->factory->get($exampleUrls[1]); + + $this->expectException(AssertionFailedError::class); + + $this->factory->assertSentInOrder($exampleUrls); + } + + public function testWrongNumberOfRequestsThrowAssertionFailed() + { + $this->factory->fake(); + + $exampleUrls = [ + 'http://example.com/1', + 'http://example.com/2', + 'http://example.com/3', + ]; + + $this->factory->get($exampleUrls[0]); + $this->factory->get($exampleUrls[1]); + + $this->expectException(AssertionFailedError::class); + + $this->factory->assertSentInOrder($exampleUrls); + } + + public function testCanAssertAgainstOrderOfHttpRequestsWithCallables() + { + $this->factory->fake(); + + $exampleUrls = [ + function ($request) { + return $request->url() === 'http://example.com/1'; + }, + function ($request) { + return $request->url() === 'http://example.com/2'; + }, + function ($request) { + return $request->url() === 'http://example.com/3'; + }, + ]; + + $this->factory->get('http://example.com/1'); + $this->factory->get('http://example.com/2'); + $this->factory->get('http://example.com/3'); + + $this->factory->assertSentInOrder($exampleUrls); + } + + public function testCanAssertAgainstOrderOfHttpRequestsWithCallablesAndHeaders() + { + $this->factory->fake(); + + $executionOrder = [ + function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeader('Content-Type', 'application/json') && + $request->hasHeader('X-Test-Header', 'foo') && + $request->hasHeader('X-Test-ArrayHeader', ['bar', 'baz']) && + $request['name'] === 'Taylor'; + }, + function (Request $request) { + return $request->url() === 'http://bar.com/json' && + $request->hasHeader('Content-Type', 'application/json') && + $request->hasHeader('X-Test-Header', 'bar') && + $request->hasHeader('X-Test-ArrayHeader', ['bar', 'baz']) && + $request['name'] === 'Taylor'; + }, + ]; + + $this->factory->withHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://foo.com/json', [ + 'name' => 'Taylor', + ]); + + $this->factory->withHeaders([ + 'X-Test-Header' => 'bar', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://bar.com/json', [ + 'name' => 'Taylor', + ]); + + $this->factory->assertSentInOrder($executionOrder); + } + + public function testCanAssertAgainstOrderOfHttpRequestsWithCallablesAndHeadersFailsCorrectly() + { + $this->factory->fake(); + + $executionOrder = [ + function (Request $request) { + return $request->url() === 'http://bar.com/json' && + $request->hasHeader('Content-Type', 'application/json') && + $request->hasHeader('X-Test-Header', 'bar') && + $request->hasHeader('X-Test-ArrayHeader', ['bar', 'baz']) && + $request['name'] === 'Taylor'; + }, + function (Request $request) { + return $request->url() === 'http://foo.com/json' && + $request->hasHeader('Content-Type', 'application/json') && + $request->hasHeader('X-Test-Header', 'foo') && + $request->hasHeader('X-Test-ArrayHeader', ['bar', 'baz']) && + $request['name'] === 'Taylor'; + }, + ]; + + $this->factory->withHeaders([ + 'X-Test-Header' => 'foo', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://foo.com/json', [ + 'name' => 'Taylor', + ]); + + $this->factory->withHeaders([ + 'X-Test-Header' => 'bar', + 'X-Test-ArrayHeader' => ['bar', 'baz'], + ])->post('http://bar.com/json', [ + 'name' => 'Taylor', + ]); + + $this->expectException(AssertionFailedError::class); + + $this->factory->assertSentInOrder($executionOrder); + } + + public function testCanDump() + { + $dumped = []; + + VarDumper::setHandler(function ($value) use (&$dumped) { + $dumped[] = $value; + }); + + $this->factory->fake()->dump(1, 2, 3)->withOptions(['delay' => 1000])->get('http://foo.com'); + + $this->assertSame(1, $dumped[0]); + $this->assertSame(2, $dumped[1]); + $this->assertSame(3, $dumped[2]); + $this->assertInstanceOf(Request::class, $dumped[3]); + $this->assertSame(1000, $dumped[4]['delay']); + + VarDumper::setHandler(null); + } + + public function testResponseSequenceIsMacroable() + { + ResponseSequence::macro('customMethod', function () { + return 'yes!'; + }); + + $this->assertSame('yes!', $this->factory->fakeSequence()->customMethod()); + } + + public function testRequestsCanBeAsync() + { + $request = new PendingRequest($this->factory); + + $promise = $request->async()->get('http://foo.com'); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $this->assertSame($promise, $request->getPromise()); + } + + public function testClientCanBeSet() + { + $client = $this->factory->buildClient(); + + $request = new PendingRequest($this->factory); + + $this->assertNotSame($client, $request->buildClient()); + + $request->setClient($client); + + $this->assertSame($client, $request->buildClient()); + } + + public function testRequestsCanReplaceOptions() + { + $request = new PendingRequest($this->factory); + + $request = $request->withOptions(['http_errors' => true, 'connect_timeout' => 10]); + + $this->assertSame(['http_errors' => true, 'connect_timeout' => 10], $request->getOptions()); + + $request = $request->withOptions(['connect_timeout' => 20]); + + $this->assertSame(['http_errors' => true, 'connect_timeout' => 20], $request->getOptions()); + } + + public function testMultipleRequestsAreSentInThePool() + { + $this->factory->fake([ + '200.com' => $this->factory::response('', 200), + '400.com' => $this->factory::response('', 400), + '500.com' => $this->factory::response('', 500), + ]); + + $responses = $this->factory->pool(function (Pool $pool) { + return [ + $pool->get('200.com'), + $pool->get('400.com'), + $pool->get('500.com'), + ]; + }); + + $this->assertSame(200, $responses[0]->status()); + $this->assertSame(400, $responses[1]->status()); + $this->assertSame(500, $responses[2]->status()); + } + + public function testMultipleRequestsAreSentInThePoolWithKeys() + { + $this->factory->fake([ + '200.com' => $this->factory::response('', 200), + '400.com' => $this->factory::response('', 400), + '500.com' => $this->factory::response('', 500), + ]); + + $responses = $this->factory->pool(function (Pool $pool) { + return [ + $pool->as('test200')->get('200.com'), + $pool->as('test400')->get('400.com'), + $pool->as('test500')->get('500.com'), + ]; + }); + + $this->assertSame(200, $responses['test200']->status()); + $this->assertSame(400, $responses['test400']->status()); + $this->assertSame(500, $responses['test500']->status()); + } + + public function testTheRequestSendingAndResponseReceivedEventsAreFiredWhenARequestIsSent() + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->times(5)->with(m::type(RequestSending::class)); + $events->shouldReceive('dispatch')->times(5)->with(m::type(ResponseReceived::class)); + + $factory = new Factory($events); + $factory->fake(); + + $factory->get('https://example.com'); + $factory->head('https://example.com'); + $factory->post('https://example.com'); + $factory->patch('https://example.com'); + $factory->delete('https://example.com'); + } + + public function testTheRequestSendingAndResponseReceivedEventsAreFiredWhenARequestIsSentAsync() + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->times(5)->with(m::type(RequestSending::class)); + $events->shouldReceive('dispatch')->times(5)->with(m::type(ResponseReceived::class)); + + $factory = new Factory($events); + $factory->fake(); + $factory->pool(function (Pool $pool) { + return [ + $pool->get('https://example.com'), + $pool->head('https://example.com'), + $pool->post('https://example.com'), + $pool->patch('https://example.com'), + $pool->delete('https://example.com'), + ]; + }); + } + + public function testTheTransferStatsAreCalledSafelyWhenFakingTheRequest() + { + $this->factory->fake(['https://example.com' => ['world' => 'Hello world']]); + $stats = $this->factory->get('https://example.com')->handlerStats(); + $effectiveUri = $this->factory->get('https://example.com')->effectiveUri(); + + $this->assertIsArray($stats); + $this->assertEmpty($stats); + + $this->assertNull($effectiveUri); + } + + public function testTransferStatsArePresentWhenFakingTheRequestUsingAPromiseResponse() + { + $this->factory->fake(['https://example.com' => $this->factory->response()]); + $effectiveUri = $this->factory->get('https://example.com')->effectiveUri(); + + $this->assertSame('https://example.com', (string) $effectiveUri); + } + + public function testClonedClientsWorkSuccessfullyWithTheRequestObject() + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->once()->with(m::type(RequestSending::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(ResponseReceived::class)); + + $factory = new Factory($events); + $factory->fake(['example.com' => $factory->response('foo', 200)]); + + $client = $factory->timeout(10); + $clonedClient = clone $client; + + $clonedClient->get('https://example.com'); + } + + public function testRequestIsMacroable() + { + Request::macro('customMethod', function () { + return 'yes!'; + }); + + $this->factory->fake(function (Request $request) { + $this->assertSame('yes!', $request->customMethod()); + + return $this->factory->response(); + }); + + $this->factory->get('https://example.com'); + } + + public function testItCanAddAuthorizationHeaderIntoRequestUsingBeforeSendingCallback() + { + $this->factory->fake(); + + $this->factory->beforeSending(function (Request $request) { + $requestLine = sprintf( + '%s %s HTTP/%s', + $request->toPsrRequest()->getMethod(), + $request->toPsrRequest()->getUri()->withScheme('')->withHost(''), + $request->toPsrRequest()->getProtocolVersion() + ); + + return $request->toPsrRequest()->withHeader('Authorization', 'Bearer '.$requestLine); + })->get('http://foo.com/json'); + + $this->factory->assertSent(function (Request $request) { + return + $request->url() === 'http://foo.com/json' && + $request->hasHeader('Authorization', 'Bearer GET /json HTTP/1.1'); + }); + } +} diff --git a/tests/Http/HttpJsonResponseTest.php b/tests/Http/HttpJsonResponseTest.php index b8a286839d6c8bb331ea46a6e5c2ae3042f44f01..5fac81d570eba24dc832b7c81680d25164fee335 100644 --- a/tests/Http/HttpJsonResponseTest.php +++ b/tests/Http/HttpJsonResponseTest.php @@ -30,6 +30,7 @@ class HttpJsonResponseTest extends TestCase 'JsonSerializable data' => [new JsonResponseTestJsonSerializeObject], 'Arrayable data' => [new JsonResponseTestArrayableObject], 'Array data' => [['foo' => 'bar']], + 'stdClass data' => [(object) ['foo' => 'bar']], ]; } @@ -104,6 +105,14 @@ class HttpJsonResponseTest extends TestCase [$nan], ]; } + + public function testFromJsonString() + { + $json_string = '{"foo":"bar"}'; + $response = JsonResponse::fromJsonString($json_string); + + $this->assertSame('bar', $response->getData()->foo); + } } class JsonResponseTestJsonableObject implements Jsonable @@ -116,7 +125,7 @@ class JsonResponseTestJsonableObject implements Jsonable class JsonResponseTestJsonSerializeObject implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return ['foo' => 'bar']; } diff --git a/tests/Http/HttpMimeTypeTest.php b/tests/Http/HttpMimeTypeTest.php index d297af86e387ca91bb4b2335d4c669381692a037..e3bd7c7f179671626b3e513b17384f20a820377c 100755 --- a/tests/Http/HttpMimeTypeTest.php +++ b/tests/Http/HttpMimeTypeTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Http; use Illuminate\Http\Testing\MimeType; use PHPUnit\Framework\TestCase; +use Symfony\Component\Mime\MimeTypesInterface; class HttpMimeTypeTest extends TestCase { @@ -27,16 +28,14 @@ class HttpMimeTypeTest extends TestCase $this->assertSame('application/octet-stream', MimeType::get('bar')); } - public function testGetAllMimeTypes() + public function testMimeTypeSymfonyInstance() { - $this->assertIsArray(MimeType::get()); - $this->assertArrayHasKey('jpg', MimeType::get()); - $this->assertSame('image/jpeg', MimeType::get()['jpg']); + $this->assertInstanceOf(MimeTypesInterface::class, MimeType::getMimeTypes()); } public function testSearchExtensionFromMimeType() { - $this->assertSame('mov', MimeType::search('video/quicktime')); + $this->assertContains(MimeType::search('video/quicktime'), ['qt', 'mov']); $this->assertNull(MimeType::search('foo/bar')); } } diff --git a/tests/Http/HttpRedirectResponseTest.php b/tests/Http/HttpRedirectResponseTest.php index a2fc86ec0efad46645c1bd9ffc986040d0e64065..5b369c0a02bdb6b67224d868f75841bb7f510fdb 100755 --- a/tests/Http/HttpRedirectResponseTest.php +++ b/tests/Http/HttpRedirectResponseTest.php @@ -52,6 +52,20 @@ class HttpRedirectResponseTest extends TestCase $this->assertSame('bar', $cookies[0]->getValue()); } + public function testFragmentIdentifierOnRedirect() + { + $response = new RedirectResponse('foo.bar'); + + $response->withFragment('foo'); + $this->assertSame('foo', parse_url($response->getTargetUrl(), PHP_URL_FRAGMENT)); + + $response->withFragment('#bar'); + $this->assertSame('bar', parse_url($response->getTargetUrl(), PHP_URL_FRAGMENT)); + + $response->withoutFragment(); + $this->assertNull(parse_url($response->getTargetUrl(), PHP_URL_FRAGMENT)); + } + public function testInputOnRedirect() { $response = new RedirectResponse('foo.bar'); diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index 6bf7b1e1c665ca3e9ca82c0ca9478e8b76b83fdf..e401ce960cd0fcb31a228acea1e286cc2d3fcd3b 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -6,6 +6,9 @@ use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Route; use Illuminate\Session\Store; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -21,7 +24,7 @@ class HttpRequestTest extends TestCase public function testInstanceMethod() { - $request = Request::create('', 'GET'); + $request = Request::create(''); $this->assertSame($request, $request->instance()); } @@ -57,10 +60,10 @@ class HttpRequestTest extends TestCase public function testPathMethod() { - $request = Request::create('', 'GET'); + $request = Request::create(''); $this->assertSame('/', $request->path()); - $request = Request::create('/foo/bar', 'GET'); + $request = Request::create('/foo/bar'); $this->assertSame('foo/bar', $request->path()); } @@ -75,7 +78,7 @@ class HttpRequestTest extends TestCase */ public function testSegmentMethod($path, $segment, $expected) { - $request = Request::create($path, 'GET'); + $request = Request::create($path); $this->assertEquals($expected, $request->segment($segment, 'default')); } @@ -94,10 +97,10 @@ class HttpRequestTest extends TestCase */ public function testSegmentsMethod($path, $expected) { - $request = Request::create($path, 'GET'); + $request = Request::create($path); $this->assertEquals($expected, $request->segments()); - $request = Request::create('foo/bar', 'GET'); + $request = Request::create('foo/bar'); $this->assertEquals(['foo', 'bar'], $request->segments()); } @@ -113,60 +116,60 @@ class HttpRequestTest extends TestCase public function testUrlMethod() { - $request = Request::create('http://foo.com/foo/bar?name=taylor', 'GET'); + $request = Request::create('http://foo.com/foo/bar?name=taylor'); $this->assertSame('http://foo.com/foo/bar', $request->url()); - $request = Request::create('http://foo.com/foo/bar/?', 'GET'); + $request = Request::create('http://foo.com/foo/bar/?'); $this->assertSame('http://foo.com/foo/bar', $request->url()); } public function testFullUrlMethod() { - $request = Request::create('http://foo.com/foo/bar?name=taylor', 'GET'); + $request = Request::create('http://foo.com/foo/bar?name=taylor'); $this->assertSame('http://foo.com/foo/bar?name=taylor', $request->fullUrl()); - $request = Request::create('https://foo.com', 'GET'); + $request = Request::create('https://foo.com'); $this->assertSame('https://foo.com', $request->fullUrl()); - $request = Request::create('https://foo.com', 'GET'); + $request = Request::create('https://foo.com'); $this->assertSame('https://foo.com/?coupon=foo', $request->fullUrlWithQuery(['coupon' => 'foo'])); - $request = Request::create('https://foo.com?a=b', 'GET'); + $request = Request::create('https://foo.com?a=b'); $this->assertSame('https://foo.com/?a=b', $request->fullUrl()); - $request = Request::create('https://foo.com?a=b', 'GET'); + $request = Request::create('https://foo.com?a=b'); $this->assertSame('https://foo.com/?a=b&coupon=foo', $request->fullUrlWithQuery(['coupon' => 'foo'])); - $request = Request::create('https://foo.com?a=b', 'GET'); + $request = Request::create('https://foo.com?a=b'); $this->assertSame('https://foo.com/?a=c', $request->fullUrlWithQuery(['a' => 'c'])); - $request = Request::create('http://foo.com/foo/bar?name=taylor', 'GET'); + $request = Request::create('http://foo.com/foo/bar?name=taylor'); $this->assertSame('http://foo.com/foo/bar?name=taylor', $request->fullUrlWithQuery(['name' => 'taylor'])); - $request = Request::create('http://foo.com/foo/bar/?name=taylor', 'GET'); + $request = Request::create('http://foo.com/foo/bar/?name=taylor'); $this->assertSame('http://foo.com/foo/bar?name=graham', $request->fullUrlWithQuery(['name' => 'graham'])); - $request = Request::create('https://foo.com', 'GET'); + $request = Request::create('https://foo.com'); $this->assertSame('https://foo.com/?key=value%20with%20spaces', $request->fullUrlWithQuery(['key' => 'value with spaces'])); } public function testIsMethod() { - $request = Request::create('/foo/bar', 'GET'); + $request = Request::create('/foo/bar'); $this->assertTrue($request->is('foo*')); $this->assertFalse($request->is('bar*')); $this->assertTrue($request->is('*bar*')); $this->assertTrue($request->is('bar*', 'foo*', 'baz')); - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $this->assertTrue($request->is('/')); } public function testFullUrlIsMethod() { - $request = Request::create('http://example.com/foo/bar', 'GET'); + $request = Request::create('http://example.com/foo/bar'); $this->assertTrue($request->fullUrlIs('http://example.com/foo/bar')); $this->assertFalse($request->fullUrlIs('example.com*')); @@ -178,7 +181,7 @@ class HttpRequestTest extends TestCase public function testRouteIsMethod() { - $request = Request::create('/foo/bar', 'GET'); + $request = Request::create('/foo/bar'); $this->assertFalse($request->routeIs('foo.bar')); @@ -196,7 +199,7 @@ class HttpRequestTest extends TestCase public function testRouteMethod() { - $request = Request::create('/foo/bar', 'GET'); + $request = Request::create('/foo/bar'); $request->setRouteResolver(function () use ($request) { $route = new Route('GET', '/foo/{required}/{optional?}', []); @@ -213,7 +216,7 @@ class HttpRequestTest extends TestCase public function testAjaxMethod() { - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $this->assertFalse($request->ajax()); $request = Request::create('/', 'GET', [], [], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], '{}'); $this->assertTrue($request->ajax()); @@ -226,7 +229,7 @@ class HttpRequestTest extends TestCase public function testPrefetchMethod() { - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $this->assertFalse($request->prefetch()); $request->server->set('HTTP_X_MOZ', ''); @@ -260,9 +263,9 @@ class HttpRequestTest extends TestCase public function testSecureMethod() { - $request = Request::create('http://example.com', 'GET'); + $request = Request::create('http://example.com'); $this->assertFalse($request->secure()); - $request = Request::create('https://example.com', 'GET'); + $request = Request::create('https://example.com'); $this->assertTrue($request->secure()); } @@ -300,6 +303,76 @@ class HttpRequestTest extends TestCase $this->assertTrue($request->has('foo.baz')); } + public function testWhenHasMethod() + { + $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); + + $name = $age = $city = $foo = $bar = false; + + $request->whenHas('name', function ($value) use (&$name) { + $name = $value; + }); + + $request->whenHas('age', function ($value) use (&$age) { + $age = $value; + }); + + $request->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $request->whenHas('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $request->whenHas('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertSame('', $age); + $this->assertNull($city); + $this->assertFalse($foo); + $this->assertTrue($bar); + } + + public function testWhenFilledMethod() + { + $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); + + $name = $age = $city = $foo = $bar = false; + + $request->whenFilled('name', function ($value) use (&$name) { + $name = $value; + }); + + $request->whenFilled('age', function ($value) use (&$age) { + $age = 'test'; + }); + + $request->whenFilled('city', function ($value) use (&$city) { + $city = 'test'; + }); + + $request->whenFilled('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $request->whenFilled('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertFalse($age); + $this->assertFalse($city); + $this->assertFalse($foo); + $this->assertTrue($bar); + } + public function testMissingMethod() { $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); @@ -360,7 +433,7 @@ class HttpRequestTest extends TestCase $this->assertTrue($request->filled('name')); $this->assertTrue($request->filled('name', 'email')); - //test arrays within query string + // test arrays within query string $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz']]); $this->assertTrue($request->filled('foo')); @@ -368,6 +441,23 @@ class HttpRequestTest extends TestCase $this->assertTrue($request->filled('foo.bar')); } + public function testIsNotFilledMethod() + { + $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); + $this->assertFalse($request->isNotFilled('name')); + $this->assertTrue($request->isNotFilled('age')); + $this->assertTrue($request->isNotFilled('city')); + $this->assertTrue($request->isNotFilled('foo')); + $this->assertFalse($request->isNotFilled(['name', 'email'])); + $this->assertTrue($request->isNotFilled(['foo', 'age'])); + $this->assertTrue($request->isNotFilled(['age', 'city'])); + + $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz' => '0']]); + $this->assertFalse($request->isNotFilled('foo')); + $this->assertTrue($request->isNotFilled('foo.bar')); + $this->assertFalse($request->isNotFilled('foo.baz')); + } + public function testFilledAnyMethod() { $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); @@ -415,6 +505,84 @@ class HttpRequestTest extends TestCase $this->assertFalse($request->boolean('some_undefined_key')); } + public function testCollectMethod() + { + $request = Request::create('/', 'GET', ['users' => [1, 2, 3]]); + + $this->assertInstanceOf(Collection::class, $request->collect('users')); + $this->assertTrue($request->collect('developers')->isEmpty()); + $this->assertEquals([1, 2, 3], $request->collect('users')->all()); + $this->assertEquals(['users' => [1, 2, 3]], $request->collect()->all()); + + $request = Request::create('/', 'GET', ['text-payload']); + $this->assertEquals(['text-payload'], $request->collect()->all()); + + $request = Request::create('/', 'GET', ['email' => 'test@example.com']); + $this->assertEquals(['test@example.com'], $request->collect('email')->all()); + + $request = Request::create('/', 'GET', []); + $this->assertInstanceOf(Collection::class, $request->collect()); + $this->assertTrue($request->collect()->isEmpty()); + + $request = Request::create('/', 'GET', ['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); + $this->assertInstanceOf(Collection::class, $request->collect(['users'])); + $this->assertTrue($request->collect(['developers'])->isEmpty()); + $this->assertTrue($request->collect(['roles'])->isNotEmpty()); + $this->assertEquals(['roles' => [4, 5, 6]], $request->collect(['roles'])->all()); + $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $request->collect(['users', 'email'])->all()); + $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $request->collect(['roles', 'foo'])); + $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $request->collect()->all()); + } + + public function testDateMethod() + { + $request = Request::create('/', 'GET', [ + 'as_null' => null, + 'as_invalid' => 'invalid', + + 'as_datetime' => '20-01-01 16:30:25', + 'as_format' => '1577896225', + 'as_timezone' => '20-01-01 13:30:25', + + 'as_date' => '2020-01-01', + 'as_time' => '16:30:25', + ]); + + $current = Carbon::create(2020, 1, 1, 16, 30, 25); + + $this->assertNull($request->date('as_null')); + $this->assertNull($request->date('doesnt_exists')); + + $this->assertEquals($current, $request->date('as_datetime')); + $this->assertEquals($current, $request->date('as_format', 'U')); + $this->assertEquals($current, $request->date('as_timezone', null, 'America/Santiago')); + + $this->assertTrue($request->date('as_date')->isSameDay($current)); + $this->assertTrue($request->date('as_time')->isSameSecond('16:30:25')); + } + + public function testDateMethodExceptionWhenValueInvalid() + { + $this->expectException(InvalidArgumentException::class); + + $request = Request::create('/', 'GET', [ + 'date' => 'invalid', + ]); + + $request->date('date'); + } + + public function testDateMethodExceptionWhenFormatInvalid() + { + $this->expectException(InvalidArgumentException::class); + + $request = Request::create('/', 'GET', [ + 'date' => '20-01-01 16:30:25', + ]); + + $request->date('date', 'invalid_format'); + } + public function testArrayAccess() { $request = Request::create('/', 'GET', ['name' => null, 'foo' => ['bar' => null, 'baz' => '']]); @@ -428,8 +596,8 @@ class HttpRequestTest extends TestCase return $route; }); - $this->assertFalse(isset($request['non-existant'])); - $this->assertNull($request['non-existant']); + $this->assertFalse(isset($request['non-existent'])); + $this->assertNull($request['non-existent']); $this->assertTrue(isset($request['name'])); $this->assertNull($request['name']); @@ -445,6 +613,17 @@ class HttpRequestTest extends TestCase $this->assertSame('foo', $request['id']); } + public function testArrayAccessWithoutRouteResolver() + { + $request = Request::create('/', 'GET', ['name' => 'Taylor']); + + $this->assertFalse(isset($request['non-existent'])); + $this->assertNull($request['non-existent']); + + $this->assertTrue(isset($request['name'])); + $this->assertSame('Taylor', $request['name']); + } + public function testAllMethod() { $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => null]); @@ -506,6 +685,14 @@ class HttpRequestTest extends TestCase $this->assertSame('Bob', $request->query('foo', 'Bob')); $all = $request->query(null); $this->assertSame('Taylor', $all['name']); + + $request = Request::create('/', 'GET', ['hello' => 'world', 'user' => ['Taylor', 'Mohamed Said']]); + $this->assertSame(['Taylor', 'Mohamed Said'], $request->query('user')); + $this->assertSame(['hello' => 'world', 'user' => ['Taylor', 'Mohamed Said']], $request->query->all()); + + $request = Request::create('/?hello=world&user[]=Taylor&user[]=Mohamed%20Said', 'GET', []); + $this->assertSame(['Taylor', 'Mohamed Said'], $request->query('user')); + $this->assertSame(['hello' => 'world', 'user' => ['Taylor', 'Mohamed Said']], $request->query->all()); } public function testPostMethod() @@ -584,6 +771,21 @@ class HttpRequestTest extends TestCase $this->assertSame('Dayle', $request->input('buddy')); } + public function testMergeIfMissingMethod() + { + $request = Request::create('/', 'GET', ['name' => 'Taylor']); + $merge = ['boolean_setting' => 0]; + $request->mergeIfMissing($merge); + $this->assertSame('Taylor', $request->input('name')); + $this->assertSame(0, $request->input('boolean_setting')); + + $request = Request::create('/', 'GET', ['name' => 'Taylor', 'boolean_setting' => 1]); + $merge = ['boolean_setting' => 0]; + $request->mergeIfMissing($merge); + $this->assertSame('Taylor', $request->input('name')); + $this->assertSame(1, $request->input('boolean_setting')); + } + public function testReplaceMethod() { $request = Request::create('/', 'GET', ['name' => 'Taylor']); @@ -608,6 +810,18 @@ class HttpRequestTest extends TestCase $this->assertSame('foo', $all['do-this'][0]); } + public function testBearerTokenMethod() + { + $request = Request::create('/', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer fooBearerbar']); + $this->assertSame('fooBearerbar', $request->bearerToken()); + + $request = Request::create('/', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Basic foo, Bearer bar']); + $this->assertSame('bar', $request->bearerToken()); + + $request = Request::create('/', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer foo,bar']); + $this->assertSame('foo', $request->bearerToken()); + } + public function testJSONMethod() { $payload = ['name' => 'taylor']; @@ -631,29 +845,41 @@ class HttpRequestTest extends TestCase $this->assertEquals($payload, $data); } - public function testPrefers() + public function getPrefersCases() { - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json'])->prefers(['json'])); - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json'])->prefers(['html', 'json'])); - $this->assertSame('application/foo+json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/foo+json'])->prefers('application/foo+json')); - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/foo+json'])->prefers('json')); - $this->assertSame('html', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json;q=0.5, text/html;q=1.0'])->prefers(['json', 'html'])); - $this->assertSame('txt', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json;q=0.5, text/plain;q=1.0, text/html;q=1.0'])->prefers(['json', 'txt', 'html'])); - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/*'])->prefers('json')); - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json; charset=utf-8'])->prefers('json')); - $this->assertNull(Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/xml; charset=utf-8'])->prefers(['html', 'json'])); - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json, text/html'])->prefers(['html', 'json'])); - $this->assertSame('html', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json;q=0.4, text/html;q=0.6'])->prefers(['html', 'json'])); - - $this->assertSame('application/json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json; charset=utf-8'])->prefers('application/json')); - $this->assertSame('application/json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json, text/html'])->prefers(['text/html', 'application/json'])); - $this->assertSame('text/html', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json;q=0.4, text/html;q=0.6'])->prefers(['text/html', 'application/json'])); - $this->assertSame('text/html', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json;q=0.4, text/html;q=0.6'])->prefers(['application/json', 'text/html'])); + return [ + ['application/json', ['json'], 'json'], + ['application/json', ['html', 'json'], 'json'], + ['application/foo+json', 'application/foo+json', 'application/foo+json'], + ['application/foo+json', 'json', 'json'], + ['application/json;q=0.5, text/html;q=1.0', ['json', 'html'], 'html'], + ['application/json;q=0.5, text/plain;q=1.0, text/html;q=1.0', ['json', 'txt', 'html'], 'txt'], + ['application/*', 'json', 'json'], + ['application/json; charset=utf-8', 'json', 'json'], + ['application/xml; charset=utf-8', ['html', 'json'], null], + ['application/json, text/html', ['html', 'json'], 'json'], + ['application/json;q=0.4, text/html;q=0.6', ['html', 'json'], 'html'], + + ['application/json; charset=utf-8', 'application/json', 'application/json'], + ['application/json, text/html', ['text/html', 'application/json'], 'application/json'], + ['application/json;q=0.4, text/html;q=0.6', ['text/html', 'application/json'], 'text/html'], + ['application/json;q=0.4, text/html;q=0.6', ['application/json', 'text/html'], 'text/html'], + + ['*/*; charset=utf-8', 'json', 'json'], + ['application/*', 'application/json', 'application/json'], + ['application/*', 'application/xml', 'application/xml'], + ['application/*', 'text/html', null], + ]; + } - $this->assertSame('json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => '*/*; charset=utf-8'])->prefers('json')); - $this->assertSame('application/json', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/*'])->prefers('application/json')); - $this->assertSame('application/xml', Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/*'])->prefers('application/xml')); - $this->assertNull(Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/*'])->prefers('text/html')); + /** + * @dataProvider getPrefersCases + */ + public function testPrefersMethod($accept, $prefers, $expected) + { + $this->assertSame( + $expected, Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => $accept])->prefers($prefers) + ); } public function testAllInputReturnsInputAndFiles() @@ -726,7 +952,7 @@ class HttpRequestTest extends TestCase public function testOldMethodCallsSession() { - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $session = m::mock(Store::class); $session->shouldReceive('getOldInput')->once()->with('foo', 'bar')->andReturn('boom'); $request->setLaravelSession($session); @@ -735,7 +961,7 @@ class HttpRequestTest extends TestCase public function testFlushMethodCallsSession() { - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $session = m::mock(Store::class); $session->shouldReceive('flashInput')->once(); $request->setLaravelSession($session); @@ -880,12 +1106,27 @@ class HttpRequestTest extends TestCase $this->assertFalse($request->accepts('text/html')); } + public function testCaseInsensitiveAcceptHeader() + { + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'APPLICATION/JSON']); + $this->assertTrue($request->accepts(['text/html', 'application/json'])); + + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'AppLiCaTion/JsOn']); + $this->assertTrue($request->accepts(['text/html', 'application/json'])); + + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'APPLICATION/*']); + $this->assertTrue($request->accepts(['text/html', 'application/json'])); + + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'APPLICATION/JSON']); + $this->assertTrue($request->expectsJson()); + } + public function testSessionMethod() { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Session store not set on request.'); - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $request->session(); } @@ -949,19 +1190,19 @@ class HttpRequestTest extends TestCase $request = Request::create('/', 'GET', ['foo' => 'bar', 'empty' => '']); // Parameter 'foo' is 'bar', then it ISSET and is NOT EMPTY. - $this->assertEquals($request->foo, 'bar'); - $this->assertEquals(isset($request->foo), true); - $this->assertEquals(empty($request->foo), false); + $this->assertSame('bar', $request->foo); + $this->assertTrue(isset($request->foo)); + $this->assertNotEmpty($request->foo); // Parameter 'empty' is '', then it ISSET and is EMPTY. - $this->assertEquals($request->empty, ''); + $this->assertSame('', $request->empty); $this->assertTrue(isset($request->empty)); $this->assertEmpty($request->empty); // Parameter 'undefined' is undefined/null, then it NOT ISSET and is EMPTY. - $this->assertEquals($request->undefined, null); - $this->assertEquals(isset($request->undefined), false); - $this->assertEquals(empty($request->undefined), true); + $this->assertNull($request->undefined); + $this->assertFalse(isset($request->undefined)); + $this->assertEmpty($request->undefined); // Simulates Route parameters. $request = Request::create('/example/bar', 'GET', ['xyz' => 'overwritten']); @@ -975,22 +1216,22 @@ class HttpRequestTest extends TestCase // Router parameter 'foo' is 'bar', then it ISSET and is NOT EMPTY. $this->assertSame('bar', $request->foo); $this->assertSame('bar', $request['foo']); - $this->assertEquals(isset($request->foo), true); - $this->assertEquals(empty($request->foo), false); + $this->assertTrue(isset($request->foo)); + $this->assertNotEmpty($request->foo); // Router parameter 'undefined' is undefined/null, then it NOT ISSET and is EMPTY. - $this->assertEquals($request->undefined, null); - $this->assertEquals(isset($request->undefined), false); - $this->assertEquals(empty($request->undefined), true); + $this->assertNull($request->undefined); + $this->assertFalse(isset($request->undefined)); + $this->assertEmpty($request->undefined); // Special case: router parameter 'xyz' is 'overwritten' by QueryString, then it ISSET and is NOT EMPTY. // Basically, QueryStrings have priority over router parameters. - $this->assertEquals($request->xyz, 'overwritten'); - $this->assertEquals(isset($request->foo), true); - $this->assertEquals(empty($request->foo), false); + $this->assertSame('overwritten', $request->xyz); + $this->assertTrue(isset($request->foo)); + $this->assertNotEmpty($request->foo); // Simulates empty QueryString and Routes. - $request = Request::create('/', 'GET'); + $request = Request::create('/'); $request->setRouteResolver(function () use ($request) { $route = new Route('GET', '/', []); $route->bind($request); @@ -999,18 +1240,18 @@ class HttpRequestTest extends TestCase }); // Parameter 'undefined' is undefined/null, then it NOT ISSET and is EMPTY. - $this->assertEquals($request->undefined, null); - $this->assertEquals(isset($request->undefined), false); - $this->assertEquals(empty($request->undefined), true); + $this->assertNull($request->undefined); + $this->assertFalse(isset($request->undefined)); + $this->assertEmpty($request->undefined); // Special case: simulates empty QueryString and Routes, without the Route Resolver. // It'll happen when you try to get a parameter outside a route. - $request = Request::create('/', 'GET'); + $request = Request::create('/'); // Parameter 'undefined' is undefined/null, then it NOT ISSET and is EMPTY. - $this->assertEquals($request->undefined, null); - $this->assertEquals(isset($request->undefined), false); - $this->assertEquals(empty($request->undefined), true); + $this->assertNull($request->undefined); + $this->assertFalse(isset($request->undefined)); + $this->assertEmpty($request->undefined); } public function testHttpRequestFlashCallsSessionFlashInputWithInputData() diff --git a/tests/Http/HttpResponseTest.php b/tests/Http/HttpResponseTest.php index 0674b77118c4370b3b5fda8769b3587349485de7..b0d9381395602987ded703ca5d634bf14e6d1262 100755 --- a/tests/Http/HttpResponseTest.php +++ b/tests/Http/HttpResponseTest.php @@ -113,6 +113,13 @@ class HttpResponseTest extends TestCase $this->assertSame(404, $response->getStatusCode()); } + public function testSetStatusCodeAndRetrieveStatusText() + { + $response = new Response('foo'); + $response->setStatusCode(404); + $this->assertSame('Not Found', $response->statusText()); + } + public function testOnlyInputOnRedirect() { $response = new RedirectResponse('foo.bar'); @@ -238,7 +245,7 @@ class JsonableStub implements Jsonable class JsonSerializableStub implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return ['foo' => 'bar']; } diff --git a/tests/Http/HttpTestingFileFactoryTest.php b/tests/Http/HttpTestingFileFactoryTest.php index d9b888a74a32c1c98dcc23eb56684501f06410e0..cfd536f580d0a6e8939aefd27cf71fba178832ad 100644 --- a/tests/Http/HttpTestingFileFactoryTest.php +++ b/tests/Http/HttpTestingFileFactoryTest.php @@ -5,14 +5,13 @@ namespace Illuminate\Tests\Http; use Illuminate\Http\Testing\FileFactory; use PHPUnit\Framework\TestCase; +/** + * @requires extension gd + */ class HttpTestingFileFactoryTest extends TestCase { public function testImagePng() { - if (! function_exists('imagepng')) { - $this->markTestSkipped('The extension gd is missing from your system or was compiled without PNG support.'); - } - $image = (new FileFactory)->image('test.png', 15, 20); $info = getimagesize($image->getRealPath()); @@ -24,17 +23,59 @@ class HttpTestingFileFactoryTest extends TestCase public function testImageJpeg() { - if (! function_exists('imagejpeg')) { - $this->markTestSkipped('The extension gd is missing from your system or was compiled without JPEG support.'); - } - - $image = (new FileFactory)->image('test.jpeg', 15, 20); + $jpeg = (new FileFactory)->image('test.jpeg', 15, 20); + $jpg = (new FileFactory)->image('test.jpg'); - $info = getimagesize($image->getRealPath()); + $info = getimagesize($jpeg->getRealPath()); $this->assertSame('image/jpeg', $info['mime']); $this->assertSame(15, $info[0]); $this->assertSame(20, $info[1]); + $this->assertSame( + 'image/jpeg', + mime_content_type($jpg->getRealPath()) + ); + } + + public function testImageGif() + { + $image = (new FileFactory)->image('test.gif'); + + $this->assertSame( + 'image/gif', + mime_content_type($image->getRealPath()) + ); + } + + public function testImageWebp() + { + $image = (new FileFactory)->image('test.webp'); + + $this->assertSame( + 'image/webp', + mime_content_type($image->getRealPath()) + ); + } + + public function testImageWbmp() + { + $image = (new FileFactory)->image('test.wbmp'); + + $this->assertSame( + 'image/vnd.wap.wbmp', + getimagesize($image->getRealPath())['mime'] + ); + } + + public function testImageBmp() + { + $image = (new FileFactory)->image('test.bmp'); + + $imagePath = $image->getRealPath(); + + $this->assertSame('image/x-ms-bmp', mime_content_type($imagePath)); + + $this->assertSame('image/bmp', getimagesize($imagePath)['mime']); } public function testCreateWithMimeType() diff --git a/tests/Http/HttpUploadedFileTest.php b/tests/Http/HttpUploadedFileTest.php index e8d4f7c11fb8329e49856a6852449f19a0cd92ee..35850eb487299e7b30bb1dc872f94a84ced65c4d 100644 --- a/tests/Http/HttpUploadedFileTest.php +++ b/tests/Http/HttpUploadedFileTest.php @@ -14,7 +14,6 @@ class HttpUploadedFileTest extends TestCase 'test.txt', null, null, - null, true ); diff --git a/tests/Http/Middleware/CacheTest.php b/tests/Http/Middleware/CacheTest.php index d9953e0a4567165f53978104d1c3135ac4d4f47d..0f75ed79d3a9d24bad3420a46a8c174cd7680b09 100644 --- a/tests/Http/Middleware/CacheTest.php +++ b/tests/Http/Middleware/CacheTest.php @@ -104,4 +104,15 @@ class CacheTest extends TestCase $this->assertSame(Carbon::parse($birthdate)->timestamp, $response->getLastModified()->getTimestamp()); } + + public function testTrailingDelimiterIgnored() + { + $time = time(); + + $response = (new Cache)->handle(new Request, function () { + return new Response('some content'); + }, "last_modified=$time;"); + + $this->assertSame($time, $response->getLastModified()->getTimestamp()); + } } diff --git a/tests/Http/Middleware/TrustProxiesTest.php b/tests/Http/Middleware/TrustProxiesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6f653d09874d015c53bebbb1f6ca483b7a7930e6 --- /dev/null +++ b/tests/Http/Middleware/TrustProxiesTest.php @@ -0,0 +1,379 @@ +<?php + +namespace Illuminate\Tests\Http\Middleware; + +use Illuminate\Http\Middleware\TrustProxies; +use Illuminate\Http\Request; +use PHPUnit\Framework\TestCase; + +class TrustProxiesTest extends TestCase +{ + /** + * A list of all proxy headers. + * + * @var int + */ + protected $headerAll = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Test that Symfony does indeed NOT trust X-Forwarded-* + * headers when not given trusted proxies. + * + * This re-tests Symfony's Request class, but hopefully provides + * some clarify to developers looking at the tests. + */ + public function test_request_does_not_trust() + { + $req = $this->createProxiedRequest(); + + $this->assertEquals('192.168.10.10', $req->getClientIp(), 'Assert untrusted proxy x-forwarded-for header not used'); + $this->assertEquals('http', $req->getScheme(), 'Assert untrusted proxy x-forwarded-proto header not used'); + $this->assertEquals('localhost', $req->getHost(), 'Assert untrusted proxy x-forwarded-host header not used'); + $this->assertEquals(8888, $req->getPort(), 'Assert untrusted proxy x-forwarded-port header not used'); + } + + /** + * Test that Symfony DOES indeed trust X-Forwarded-* + * headers when given trusted proxies. + * + * Again, this re-tests Symfony's Request class. + */ + public function test_does_trust_trusted_proxy() + { + $req = $this->createProxiedRequest(); + $req->setTrustedProxies(['192.168.10.10'], $this->headerAll); + + $this->assertEquals('173.174.200.38', $req->getClientIp(), 'Assert trusted proxy x-forwarded-for header used'); + $this->assertEquals('https', $req->getScheme(), 'Assert trusted proxy x-forwarded-proto header used'); + $this->assertEquals('serversforhackers.com', $req->getHost(), 'Assert trusted proxy x-forwarded-host header used'); + $this->assertEquals(443, $req->getPort(), 'Assert trusted proxy x-forwarded-port header used'); + } + + /** + * Test the next most typical usage of TrustedProxies: + * Trusted X-Forwarded-For header, wilcard for TrustedProxies. + */ + public function test_trusted_proxy_sets_trusted_proxies_with_wildcard() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, '*'); + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.38', $request->getClientIp(), 'Assert trusted proxy x-forwarded-for header used with wildcard proxy setting'); + }); + } + + /** + * Test the next most typical usage of TrustedProxies: + * Trusted X-Forwarded-For header, wilcard for TrustedProxies. + */ + public function test_trusted_proxy_sets_trusted_proxies_with_double_wildcard_for_backwards_compat() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, '**'); + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.38', $request->getClientIp(), 'Assert trusted proxy x-forwarded-for header used with wildcard proxy setting'); + }); + } + + /** + * Test the most typical usage of TrustProxies: + * Trusted X-Forwarded-For header. + */ + public function test_trusted_proxy_sets_trusted_proxies() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, ['192.168.10.10']); + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.38', $request->getClientIp(), 'Assert trusted proxy x-forwarded-for header used'); + }); + } + + /** + * Test X-Forwarded-For header with multiple IP addresses. + */ + public function test_get_client_ips() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, ['192.168.10.10']); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '192.0.2.2, 192.0.2.199, 99.99.99.99', + '192.0.2.2,192.0.2.199', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $trustedProxy->handle($request, function ($request) use ($forwardedForHeader) { + $ips = $request->getClientIps(); + $this->assertEquals('192.0.2.2', end($ips), 'Assert sets the '.$forwardedForHeader); + }); + } + } + + /** + * Test X-Forwarded-For header with multiple IP addresses, with some of those being trusted. + */ + public function test_get_client_ip_with_muliple_ip_addresses_some_of_which_are_trusted() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, ['192.168.10.10', '192.0.2.199']); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '99.99.99.99, 192.0.2.2, 192.0.2.199', + '192.0.2.2,192.0.2.199', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $trustedProxy->handle($request, function ($request) use ($forwardedForHeader) { + $this->assertEquals('192.0.2.2', $request->getClientIp(), 'Assert sets the '.$forwardedForHeader); + }); + } + } + + /** + * Test X-Forwarded-For header with multiple IP addresses, with * wildcard trusting of all proxies. + */ + public function test_get_client_ip_with_muliple_ip_addresses_all_proxies_are_trusted() + { + $trustedProxy = $this->createTrustedProxy($this->headerAll, '*'); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.199, 192.0.2.2', + '192.0.2.199,192.0.2.2', + '99.99.99.99,192.0.2.199,192.0.2.2', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $trustedProxy->handle($request, function ($request) use ($forwardedForHeader) { + $this->assertEquals('192.0.2.2', $request->getClientIp(), 'Assert sets the '.$forwardedForHeader); + }); + } + } + + /** + * Test distrusting a header. + */ + public function test_can_distrust_headers() + { + $trustedProxy = $this->createTrustedProxy(Request::HEADER_FORWARDED, ['192.168.10.10']); + + $request = $this->createProxiedRequest([ + 'HTTP_FORWARDED' => 'for=173.174.200.40:443; proto=https; host=serversforhackers.com', + 'HTTP_X_FORWARDED_FOR' => '173.174.200.38', + 'HTTP_X_FORWARDED_HOST' => 'svrs4hkrs.com', + 'HTTP_X_FORWARDED_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'http', + ]); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.40', $request->getClientIp(), + 'Assert trusted proxy used forwarded header for IP'); + $this->assertEquals('https', $request->getScheme(), + 'Assert trusted proxy used forwarded header for scheme'); + $this->assertEquals('serversforhackers.com', $request->getHost(), + 'Assert trusted proxy used forwarded header for host'); + $this->assertEquals(443, $request->getPort(), 'Assert trusted proxy used forwarded header for port'); + }); + } + + /** + * Test that only the X-Forwarded-For header is trusted. + */ + public function test_x_forwarded_for_header_only_trusted() + { + $trustedProxy = $this->createTrustedProxy(Request::HEADER_X_FORWARDED_FOR, '*'); + + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.38', $request->getClientIp(), + 'Assert trusted proxy used forwarded header for IP'); + $this->assertEquals('http', $request->getScheme(), + 'Assert trusted proxy did not use forwarded header for scheme'); + $this->assertEquals('localhost', $request->getHost(), + 'Assert trusted proxy did not use forwarded header for host'); + $this->assertEquals(8888, $request->getPort(), 'Assert trusted proxy did not use forwarded header for port'); + }); + } + + /** + * Test that only the X-Forwarded-Host header is trusted. + */ + public function test_x_forwarded_host_header_only_trusted() + { + $trustedProxy = $this->createTrustedProxy(Request::HEADER_X_FORWARDED_HOST, '*'); + + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_HOST' => 'serversforhackers.com:8888']); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('192.168.10.10', $request->getClientIp(), + 'Assert trusted proxy did not use forwarded header for IP'); + $this->assertEquals('http', $request->getScheme(), + 'Assert trusted proxy did not use forwarded header for scheme'); + $this->assertEquals('serversforhackers.com', $request->getHost(), + 'Assert trusted proxy used forwarded header for host'); + $this->assertEquals(8888, $request->getPort(), 'Assert trusted proxy did not use forwarded header for port'); + }); + } + + /** + * Test that only the X-Forwarded-Port header is trusted. + */ + public function test_x_forwarded_port_header_only_trusted() + { + $trustedProxy = $this->createTrustedProxy(Request::HEADER_X_FORWARDED_PORT, '*'); + + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('192.168.10.10', $request->getClientIp(), + 'Assert trusted proxy did not use forwarded header for IP'); + $this->assertEquals('http', $request->getScheme(), + 'Assert trusted proxy did not use forwarded header for scheme'); + $this->assertEquals('localhost', $request->getHost(), + 'Assert trusted proxy did not use forwarded header for host'); + $this->assertEquals(443, $request->getPort(), 'Assert trusted proxy used forwarded header for port'); + }); + } + + /** + * Test that only the X-Forwarded-Proto header is trusted. + */ + public function test_x_forwarded_proto_header_only_trusted() + { + $trustedProxy = $this->createTrustedProxy(Request::HEADER_X_FORWARDED_PROTO, '*'); + + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('192.168.10.10', $request->getClientIp(), + 'Assert trusted proxy did not use forwarded header for IP'); + $this->assertEquals('https', $request->getScheme(), + 'Assert trusted proxy used forwarded header for scheme'); + $this->assertEquals('localhost', $request->getHost(), + 'Assert trusted proxy did not use forwarded header for host'); + $this->assertEquals(8888, $request->getPort(), 'Assert trusted proxy did not use forwarded header for port'); + }); + } + + /** + * Test a combination of individual X-Forwarded-* headers are trusted. + */ + public function test_x_forwarded_multiple_individual_headers_trusted() + { + $trustedProxy = $this->createTrustedProxy( + Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO, + '*' + ); + + $request = $this->createProxiedRequest(); + + $trustedProxy->handle($request, function ($request) { + $this->assertEquals('173.174.200.38', $request->getClientIp(), + 'Assert trusted proxy used forwarded header for IP'); + $this->assertEquals('https', $request->getScheme(), + 'Assert trusted proxy used forwarded header for scheme'); + $this->assertEquals('serversforhackers.com', $request->getHost(), + 'Assert trusted proxy used forwarded header for host'); + $this->assertEquals(443, $request->getPort(), 'Assert trusted proxy used forwarded header for port'); + }); + } + + /** + * Test to ensure it's reading text-based configurations and converting it correctly. + */ + public function test_is_reading_text_based_configurations() + { + $request = $this->createProxiedRequest(); + + // trust *all* "X-Forwarded-*" headers + $trustedProxy = $this->createTrustedProxy('HEADER_X_FORWARDED_ALL', '192.168.1.1, 192.168.1.2'); + $trustedProxy->handle($request, function (Request $request) { + $this->assertEquals($request->getTrustedHeaderSet(), $this->headerAll, + 'Assert trusted proxy used all "X-Forwarded-*" header'); + + $this->assertEquals($request->getTrustedProxies(), ['192.168.1.1', '192.168.1.2'], + 'Assert trusted proxy using proxies as string separated by comma.'); + }); + + // or, if your proxy instead uses the "Forwarded" header + $trustedProxy = $this->createTrustedProxy('HEADER_FORWARDED', '192.168.1.1, 192.168.1.2'); + $trustedProxy->handle($request, function (Request $request) { + $this->assertEquals($request->getTrustedHeaderSet(), Request::HEADER_FORWARDED, + 'Assert trusted proxy used forwarded header'); + + $this->assertEquals($request->getTrustedProxies(), ['192.168.1.1', '192.168.1.2'], + 'Assert trusted proxy using proxies as string separated by comma.'); + }); + + // or, if you're using AWS ELB + $trustedProxy = $this->createTrustedProxy('HEADER_X_FORWARDED_AWS_ELB', '192.168.1.1, 192.168.1.2'); + $trustedProxy->handle($request, function (Request $request) { + $this->assertEquals($request->getTrustedHeaderSet(), Request::HEADER_X_FORWARDED_AWS_ELB, + 'Assert trusted proxy used AWS ELB header'); + + $this->assertEquals($request->getTrustedProxies(), ['192.168.1.1', '192.168.1.2'], + 'Assert trusted proxy using proxies as string separated by comma.'); + }); + } + + /** + * Fake an HTTP request by generating a Symfony Request object. + * + * @param array $serverOverRides + * @return \Symfony\Component\HttpFoundation\Request + */ + protected function createProxiedRequest($serverOverRides = []) + { + // Add some X-Forwarded headers and over-ride + // defaults, simulating a request made over a proxy + $serverOverRides = array_replace([ + 'HTTP_X_FORWARDED_FOR' => '173.174.200.38', // X-Forwarded-For -- getClientIp() + 'HTTP_X_FORWARDED_HOST' => 'serversforhackers.com', // X-Forwarded-Host -- getHosts() + 'HTTP_X_FORWARDED_PORT' => '443', // X-Forwarded-Port -- getPort() + 'HTTP_X_FORWARDED_PROTO' => 'https', // X-Forwarded-Proto -- getScheme() / isSecure() + 'SERVER_PORT' => 8888, + 'HTTP_HOST' => 'localhost', + 'REMOTE_ADDR' => '192.168.10.10', + ], $serverOverRides); + + // Create a fake request made over "http", one that we'd get over a proxy + // which is likely something like this: + $request = Request::create('http://localhost:8888/tag/proxy', 'GET', [], [], [], $serverOverRides, null); + // Need to make sure these haven't already been set + $request->setTrustedProxies([], $this->headerAll); + + return $request; + } + + /** + * Create an anonymous middleware class. + * + * @param null|string|int $trustedHeaders + * @param null|array|string $trustedProxies + * @return \Illuminate\Http\Middleware\TrustProxies + */ + protected function createTrustedProxy($trustedHeaders, $trustedProxies) + { + return new class($trustedHeaders, $trustedProxies) extends TrustProxies + { + public function __construct($trustedHeaders, $trustedProxies) + { + $this->headers = $trustedHeaders; + $this->proxies = $trustedProxies; + } + }; + } +} diff --git a/tests/IgnoreSkippedPrinter.php b/tests/IgnoreSkippedPrinter.php new file mode 100644 index 0000000000000000000000000000000000000000..521c3e6aa490bfc8e5fbadc571b473b22a38fad8 --- /dev/null +++ b/tests/IgnoreSkippedPrinter.php @@ -0,0 +1,26 @@ +<?php + +namespace Illuminate\Tests; + +use PHPUnit\Framework\TestResult; +use PHPUnit\Runner\Version; +use PHPUnit\TextUI\DefaultResultPrinter as PHPUnit9ResultPrinter; +use PHPUnit\TextUI\ResultPrinter as PHPUnit8ResultPrinter; + +if (class_exists(Version::class) && (int) Version::series()[0] >= 9) { + class IgnoreSkippedPrinter extends PHPUnit9ResultPrinter + { + protected function printSkipped(TestResult $result): void + { + // + } + } +} else { + class IgnoreSkippedPrinter extends PHPUnit8ResultPrinter + { + protected function printSkipped(TestResult $result): void + { + // + } + } +} diff --git a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php index cf6c6cc5246fe09e13ef7f0708f722a748d2262b..c5fc77aec88dba40e4a2b99307a0e2de1995fe2b 100644 --- a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php +++ b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Integration\Auth\ApiAuthenticationWithEloquentTest; +namespace Illuminate\Tests\Integration\Auth; use Illuminate\Database\QueryException; use Illuminate\Foundation\Auth\User as FoundationUser; @@ -15,12 +15,16 @@ class ApiAuthenticationWithEloquentTest extends TestCase { protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - // Auth configuration $app['config']->set('auth.defaults.guard', 'api'); $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('auth.guards.api', [ + 'driver' => 'token', + 'provider' => 'users', + 'hash' => false, + ]); + // Database configuration $app['config']->set('database.default', 'testbench'); diff --git a/tests/Integration/Auth/AuthenticationTest.php b/tests/Integration/Auth/AuthenticationTest.php index 50b561c8eba6a7e6982b81ce406ecdaab83055fe..388f23d030be6acbb886cc689f248b905e301fa9 100644 --- a/tests/Integration/Auth/AuthenticationTest.php +++ b/tests/Integration/Auth/AuthenticationTest.php @@ -19,25 +19,15 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Support\Testing\Fakes\EventFake; use Illuminate\Tests\Integration\Auth\Fixtures\AuthenticationTestUser; +use InvalidArgumentException; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class AuthenticationTest extends TestCase { protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); $app['config']->set('auth.providers.users.model', AuthenticationTestUser::class); - $app['config']->set('database.default', 'testbench'); - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - $app['config']->set('hashing', ['driver' => 'bcrypt']); } @@ -84,7 +74,7 @@ class AuthenticationTest extends TestCase ]); $response->assertStatus(200); - $this->assertSame('email', $response->decodeResponseJson()['email']); + $this->assertSame('email', $response->json()['email']); } public function testBasicAuthRespectsAdditionalConditions() @@ -211,7 +201,7 @@ class AuthenticationTest extends TestCase $this->assertEquals(1, $user->id); - $this->app['auth']->logoutOtherDevices('adifferentpassword'); + $this->app['auth']->logoutOtherDevices('password'); $this->assertEquals(1, $user->id); Event::assertDispatched(OtherDeviceLogout::class, function ($event) { @@ -222,6 +212,20 @@ class AuthenticationTest extends TestCase }); } + public function testPasswordMustBeValidToLogOutOtherDevices() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('current password'); + + $this->app['auth']->loginUsingId(1); + + $user = $this->app['auth']->user(); + + $this->assertEquals(1, $user->id); + + $this->app['auth']->logoutOtherDevices('adifferentpassword'); + } + public function testLoggingInOutViaAttemptRemembering() { $this->assertTrue( @@ -298,7 +302,7 @@ class AuthenticationTest extends TestCase ]; Auth::extend('myCustomDriver', function () { - return new MyCustomGuardStub(); + return new MyCustomGuardStub; }); $this->assertInstanceOf(MyCustomGuardStub::class, $this->app['auth']->guard('myGuard')); @@ -318,7 +322,7 @@ class AuthenticationTest extends TestCase ]; Auth::extend('myCustomDriver', function () { - return new MyDispatcherLessCustomGuardStub(); + return new MyDispatcherLessCustomGuardStub; }); $this->assertInstanceOf(MyDispatcherLessCustomGuardStub::class, $this->app['auth']->guard('myGuard')); @@ -335,7 +339,7 @@ class MyCustomGuardStub public function __construct() { - $this->setDispatcher(new Dispatcher()); + $this->setDispatcher(new Dispatcher); } public function setDispatcher(Dispatcher $events) diff --git a/tests/Integration/Auth/Fixtures/AuthenticationTestUser.php b/tests/Integration/Auth/Fixtures/AuthenticationTestUser.php index 5bd0b22c42679026ee30a6c614ec5d6ec25c7de2..7031905f3c66ab2401e9eded6b77a3ed0d5c2aeb 100644 --- a/tests/Integration/Auth/Fixtures/AuthenticationTestUser.php +++ b/tests/Integration/Auth/Fixtures/AuthenticationTestUser.php @@ -3,23 +3,26 @@ namespace Illuminate\Tests\Integration\Auth\Fixtures; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; class AuthenticationTestUser extends Authenticatable { + use Notifiable; + public $table = 'users'; public $timestamps = false; /** * The attributes that aren't mass assignable. * - * @var array + * @var string[] */ - protected $guarded = ['id']; + protected $guarded = []; /** * The attributes that should be hidden for arrays. * - * @var array + * @var string[] */ protected $hidden = [ 'password', 'remember_token', diff --git a/tests/Integration/Auth/Fixtures/Models/AuthenticationTestUser.php b/tests/Integration/Auth/Fixtures/Models/AuthenticationTestUser.php new file mode 100644 index 0000000000000000000000000000000000000000..cb87c571553d3670280bdf015a1d68203697ba6f --- /dev/null +++ b/tests/Integration/Auth/Fixtures/Models/AuthenticationTestUser.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Tests\Integration\Auth\Fixtures\Models; + +use Illuminate\Foundation\Auth\User as Authenticatable; + +class AuthenticationTestUser extends Authenticatable +{ + public $table = 'users'; + public $timestamps = false; + + /** + * The attributes that aren't mass assignable. + * + * @var string[] + */ + protected $guarded = []; + + /** + * The attributes that should be hidden for arrays. + * + * @var string[] + */ + protected $hidden = [ + 'password', 'remember_token', + ]; +} diff --git a/tests/Integration/Auth/ForgotPasswordTest.php b/tests/Integration/Auth/ForgotPasswordTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8d7298e90c770afd9bb785cdd2688a6dcd64baba --- /dev/null +++ b/tests/Integration/Auth/ForgotPasswordTest.php @@ -0,0 +1,127 @@ +<?php + +namespace Illuminate\Tests\Integration\Auth; + +use Illuminate\Auth\Notifications\ResetPassword; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Password; +use Illuminate\Tests\Integration\Auth\Fixtures\AuthenticationTestUser; +use Orchestra\Testbench\Factories\UserFactory; +use Orchestra\Testbench\TestCase; + +class ForgotPasswordTest extends TestCase +{ + protected function tearDown(): void + { + ResetPassword::$createUrlCallback = null; + ResetPassword::$toMailCallback = null; + + parent::tearDown(); + } + + protected function defineEnvironment($app) + { + $app['config']->set('auth.providers.users.model', AuthenticationTestUser::class); + } + + protected function defineDatabaseMigrations() + { + $this->loadLaravelMigrations(); + } + + protected function defineRoutes($router) + { + $router->get('password/reset/{token}', function ($token) { + return 'Reset password!'; + })->name('password.reset'); + + $router->get('custom/password/reset/{token}', function ($token) { + return 'Custom reset password!'; + })->name('custom.password.reset'); + } + + /** @test */ + public function it_can_send_forgot_password_email() + { + Notification::fake(); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('password.reset', ['token' => $notification->token, 'email' => $user->email]); + } + ); + } + + /** @test */ + public function it_can_send_forgot_password_email_via_create_url_using() + { + Notification::fake(); + + ResetPassword::createUrlUsing(function ($user, string $token) { + return route('custom.password.reset', $token); + }); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + + /** @test */ + public function it_can_send_forgot_password_email_via_to_mail_using() + { + Notification::fake(); + + ResetPassword::toMailUsing(function ($notifiable, $token) { + return (new MailMessage) + ->subject(__('Reset Password Notification')) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset Password'), route('custom.password.reset', $token)) + ->line(__('If you did not request a password reset, no further action is required.')); + }); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } +} diff --git a/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..787483f027d622c0e7ef42890ad521a7d1a9dc30 --- /dev/null +++ b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php @@ -0,0 +1,126 @@ +<?php + +namespace Illuminate\Tests\Integration\Auth; + +use Illuminate\Auth\Notifications\ResetPassword; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Password; +use Illuminate\Tests\Integration\Auth\Fixtures\AuthenticationTestUser; +use Orchestra\Testbench\Factories\UserFactory; +use Orchestra\Testbench\TestCase; + +class ForgotPasswordWithoutDefaultRoutesTest extends TestCase +{ + protected function tearDown(): void + { + ResetPassword::$createUrlCallback = null; + ResetPassword::$toMailCallback = null; + + parent::tearDown(); + } + + protected function defineEnvironment($app) + { + $app['config']->set('auth.providers.users.model', AuthenticationTestUser::class); + } + + protected function defineDatabaseMigrations() + { + $this->loadLaravelMigrations(); + } + + protected function defineRoutes($router) + { + $router->get('custom/password/reset/{token}', function ($token) { + return 'Custom reset password!'; + })->name('custom.password.reset'); + } + + /** @test */ + public function it_cannot_send_forgot_password_email() + { + $this->expectException('Symfony\Component\Routing\Exception\RouteNotFoundException'); + $this->expectExceptionMessage('Route [password.reset] not defined.'); + + Notification::fake(); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token, 'email' => $user->email]); + } + ); + } + + /** @test */ + public function it_can_send_forgot_password_email_via_create_url_using() + { + Notification::fake(); + + ResetPassword::createUrlUsing(function ($user, string $token) { + return route('custom.password.reset', $token); + }); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + + /** @test */ + public function it_can_send_forgot_password_email_via_to_mail_using() + { + Notification::fake(); + + ResetPassword::toMailUsing(function ($notifiable, $token) { + return (new MailMessage) + ->subject(__('Reset Password Notification')) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset Password'), route('custom.password.reset', $token)) + ->line(__('If you did not request a password reset, no further action is required.')); + }); + + UserFactory::new()->create(); + + $user = AuthenticationTestUser::first(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, $channels) use ($user) { + $message = $notification->toMail($user); + + return ! is_null($notification->token) + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } +} diff --git a/tests/Integration/Auth/GatePolicyResolutionTest.php b/tests/Integration/Auth/GatePolicyResolutionTest.php index ba232abcb262d3937a0e56c57de74d9ba2139fae..937d63d2917a443f1dbc38d0166c8c5fad263859 100644 --- a/tests/Integration/Auth/GatePolicyResolutionTest.php +++ b/tests/Integration/Auth/GatePolicyResolutionTest.php @@ -2,22 +2,39 @@ namespace Illuminate\Tests\Integration\Auth; +use Illuminate\Auth\Access\Events\GateEvaluated; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Tests\Integration\Auth\Fixtures\AuthenticationTestUser; use Illuminate\Tests\Integration\Auth\Fixtures\Policies\AuthenticationTestUserPolicy; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class GatePolicyResolutionTest extends TestCase { + public function testGateEvaluationEventIsFired() + { + Event::fake(); + + Gate::check('foo'); + + Event::assertDispatched(GateEvaluated::class); + } + public function testPolicyCanBeGuessedUsingClassConventions() { $this->assertInstanceOf( AuthenticationTestUserPolicy::class, Gate::getPolicyFor(AuthenticationTestUser::class) ); + + $this->assertInstanceOf( + AuthenticationTestUserPolicy::class, + Gate::getPolicyFor(Fixtures\Models\AuthenticationTestUser::class) + ); + + $this->assertNull( + Gate::getPolicyFor(static::class) + ); } public function testPolicyCanBeGuessedUsingCallback() diff --git a/tests/Integration/Auth/Middleware/RequirePasswordTest.php b/tests/Integration/Auth/Middleware/RequirePasswordTest.php index 0f13ce277f2e56c3bf50fabd3a29c897a0adf9bd..9b63ccbb6f7d5ef029da6dfa54f63857be82dab8 100644 --- a/tests/Integration/Auth/Middleware/RequirePasswordTest.php +++ b/tests/Integration/Auth/Middleware/RequirePasswordTest.php @@ -16,7 +16,7 @@ class RequirePasswordTest extends TestCase { $this->withoutExceptionHandling(); - /** @var Registrar $router */ + /** @var \Illuminate\Contracts\Routing\Registrar $router */ $router = $this->app->make(Registrar::class); $router->get('test-route', function (): Response { @@ -33,7 +33,7 @@ class RequirePasswordTest extends TestCase { $this->withoutExceptionHandling(); - /** @var Registrar $router */ + /** @var \Illuminate\Contracts\Routing\Registrar $router */ $router = $this->app->make(Registrar::class); $router->get('password-confirm', function (): Response { @@ -54,7 +54,7 @@ class RequirePasswordTest extends TestCase { $this->withoutExceptionHandling(); - /** @var Registrar $router */ + /** @var \Illuminate\Contracts\Routing\Registrar $router */ $router = $this->app->make(Registrar::class); $router->get('confirm', function (): Response { @@ -75,7 +75,7 @@ class RequirePasswordTest extends TestCase { $this->withoutExceptionHandling(); - /** @var Registrar $router */ + /** @var \Illuminate\Contracts\Routing\Registrar $router */ $router = $this->app->make(Registrar::class); $router->get('password-confirm', function (): Response { diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index af00462e6406a09035500a5bc33a4c879f2ddee4..aae8962ee302155fac111df93af154598460aa88 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -10,9 +10,6 @@ use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Queue; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class BroadcastManagerTest extends TestCase { public function testEventCanBeBroadcastNow() diff --git a/tests/Integration/Cache/DynamoDbStoreTest.php b/tests/Integration/Cache/DynamoDbStoreTest.php index 74897fbde8cb10d7936fc8acdd2caf64a307d47b..e59ebbfd774ba792234fb7435ea7f3eef4670ab5 100644 --- a/tests/Integration/Cache/DynamoDbStoreTest.php +++ b/tests/Integration/Cache/DynamoDbStoreTest.php @@ -2,22 +2,22 @@ namespace Illuminate\Tests\Integration\Cache; +use Aws\DynamoDb\DynamoDbClient; +use Aws\Exception\AwsException; +use Illuminate\Contracts\Cache\Repository; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class DynamoDbStoreTest extends TestCase { protected function setUp(): void { - parent::setUp(); - if (! env('DYNAMODB_CACHE_TABLE')) { $this->markTestSkipped('DynamoDB not configured.'); } + + parent::setUp(); } public function testItemsCanBeStoredAndRetrieved() @@ -74,15 +74,63 @@ class DynamoDbStoreTest extends TestCase */ protected function getEnvironmentSetUp($app) { + if (! env('DYNAMODB_CACHE_TABLE')) { + $this->markTestSkipped('DynamoDB not configured.'); + } + $app['config']->set('cache.default', 'dynamodb'); - $app['config']->set('cache.stores.dynamodb', [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => 'us-east-1', - 'table' => env('DYNAMODB_CACHE_TABLE', 'laravel_test'), - 'endpoint' => env('DYNAMODB_ENDPOINT'), + $config = $app['config']->get('cache.stores.dynamodb'); + + /** @var \Aws\DynamoDb\DynamoDbClient $client */ + $client = $app->make(Repository::class)->getStore()->getClient(); + + if ($this->dynamoTableExists($client, $config['table'])) { + return; + } + + $client->createTable([ + 'TableName' => $config['table'], + 'KeySchema' => [ + [ + 'AttributeName' => $config['attributes']['key'] ?? 'key', + 'KeyType' => 'HASH', + ], + ], + 'AttributeDefinitions' => [ + [ + 'AttributeName' => $config['attributes']['key'] ?? 'key', + 'AttributeType' => 'S', + ], + ], + 'ProvisionedThroughput' => [ + 'ReadCapacityUnits' => 1, + 'WriteCapacityUnits' => 1, + ], ]); } + + /** + * Determine if the given DynamoDB table exists. + * + * @param \Aws\DynamoDb\DynamoDbClient $client + * @param string $table + * @return bool + */ + public function dynamoTableExists(DynamoDbClient $client, $table) + { + try { + $client->describeTable([ + 'TableName' => $table, + ]); + + return true; + } catch (AwsException $e) { + if (Str::contains($e->getAwsErrorMessage(), ['resource not found', 'Cannot do operations on a non-existent table'])) { + return false; + } + + throw $e; + } + } } diff --git a/tests/Integration/Cache/FileCacheLockTest.php b/tests/Integration/Cache/FileCacheLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b4654ba311c921946440153789e33f2c35540249 --- /dev/null +++ b/tests/Integration/Cache/FileCacheLockTest.php @@ -0,0 +1,101 @@ +<?php + +namespace Illuminate\Tests\Integration\Cache; + +use Exception; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Orchestra\Testbench\TestCase; + +class FileCacheLockTest extends TestCase +{ + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('cache.default', 'file'); + } + + public function testLocksCanBeAcquiredAndReleased() + { + Cache::lock('foo')->forceRelease(); + + $lock = Cache::lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse(Cache::lock('foo', 10)->get()); + $lock->release(); + + $lock = Cache::lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse(Cache::lock('foo', 10)->get()); + Cache::lock('foo')->release(); + } + + public function testLocksCanBlockForSeconds() + { + Carbon::setTestNow(); + + Cache::lock('foo')->forceRelease(); + $this->assertSame('taylor', Cache::lock('foo', 10)->block(1, function () { + return 'taylor'; + })); + + Cache::lock('foo')->forceRelease(); + $this->assertTrue(Cache::lock('foo', 10)->block(1)); + } + + public function testConcurrentLocksAreReleasedSafely() + { + Cache::lock('foo')->forceRelease(); + + $firstLock = Cache::lock('foo', 1); + $this->assertTrue($firstLock->get()); + sleep(2); + + $secondLock = Cache::lock('foo', 10); + $this->assertTrue($secondLock->get()); + + $firstLock->release(); + + $this->assertFalse(Cache::lock('foo')->get()); + } + + public function testLocksWithFailedBlockCallbackAreReleased() + { + Cache::lock('foo')->forceRelease(); + + $firstLock = Cache::lock('foo', 10); + + try { + $firstLock->block(1, function () { + throw new Exception('failed'); + }); + } catch (Exception $e) { + // Not testing the exception, just testing the lock + // is released regardless of the how the exception + // thrown by the callback was handled. + } + + $secondLock = Cache::lock('foo', 1); + + $this->assertTrue($secondLock->get()); + } + + public function testLocksCanBeReleasedUsingOwnerToken() + { + Cache::lock('foo')->forceRelease(); + + $firstLock = Cache::lock('foo', 10); + $this->assertTrue($firstLock->get()); + $owner = $firstLock->owner(); + + $secondLock = Cache::store('file')->restoreLock('foo', $owner); + $secondLock->release(); + + $this->assertTrue(Cache::lock('foo')->get()); + } +} diff --git a/tests/Integration/Cache/MemcachedCacheLockTest.php b/tests/Integration/Cache/MemcachedCacheLockTestCase.php similarity index 96% rename from tests/Integration/Cache/MemcachedCacheLockTest.php rename to tests/Integration/Cache/MemcachedCacheLockTestCase.php index f345c07e05026fb251fad8008d683999fb340b29..50c0eeaa274678debd21c1214cb21dce813375f4 100644 --- a/tests/Integration/Cache/MemcachedCacheLockTest.php +++ b/tests/Integration/Cache/MemcachedCacheLockTestCase.php @@ -7,9 +7,9 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; /** - * @group integration + * @requires extension memcached */ -class MemcachedCacheLockTest extends MemcachedIntegrationTest +class MemcachedCacheLockTestCase extends MemcachedIntegrationTestCase { public function testMemcachedLocksCanBeAcquiredAndReleased() { diff --git a/tests/Integration/Cache/MemcachedIntegrationTest.php b/tests/Integration/Cache/MemcachedIntegrationTestCase.php similarity index 73% rename from tests/Integration/Cache/MemcachedIntegrationTest.php rename to tests/Integration/Cache/MemcachedIntegrationTestCase.php index 1248cf555f54a06aac8e60afbb64208c2a9777f2..5156be85affb4d8f0669f76eab0b2d6f39f82826 100644 --- a/tests/Integration/Cache/MemcachedIntegrationTest.php +++ b/tests/Integration/Cache/MemcachedIntegrationTestCase.php @@ -5,19 +5,12 @@ namespace Illuminate\Tests\Integration\Cache; use Memcached; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ -abstract class MemcachedIntegrationTest extends TestCase +abstract class MemcachedIntegrationTestCase extends TestCase { protected function setUp(): void { parent::setUp(); - if (! extension_loaded('memcached')) { - $this->markTestSkipped('Memcached module not installed'); - } - // Determine whether there is a running Memcached instance $testConnection = new Memcached; @@ -29,7 +22,7 @@ abstract class MemcachedIntegrationTest extends TestCase $testConnection->getVersion(); if ($testConnection->getResultCode() > Memcached::RES_SUCCESS) { - $this->markTestSkipped('Memcached could not establish a connection'); + $this->markTestSkipped('Memcached could not establish a connection.'); } $testConnection->quit(); diff --git a/tests/Integration/Cache/MemcachedTaggedCacheTest.php b/tests/Integration/Cache/MemcachedTaggedCacheTestCase.php similarity index 94% rename from tests/Integration/Cache/MemcachedTaggedCacheTest.php rename to tests/Integration/Cache/MemcachedTaggedCacheTestCase.php index 03ce1e090036790eabfd63c27f35cb4b60c3f101..4aab9422a8fda24246b83571ae4b1d49635cf5ba 100644 --- a/tests/Integration/Cache/MemcachedTaggedCacheTest.php +++ b/tests/Integration/Cache/MemcachedTaggedCacheTestCase.php @@ -5,9 +5,9 @@ namespace Illuminate\Tests\Integration\Cache; use Illuminate\Support\Facades\Cache; /** - * @group integration + * @requires extension memcached */ -class MemcachedTaggedCacheTest extends MemcachedIntegrationTest +class MemcachedTaggedCacheTestCase extends MemcachedIntegrationTestCase { public function testMemcachedCanStoreAndRetrieveTaggedCacheItems() { diff --git a/tests/Integration/Cache/NoLockTest.php b/tests/Integration/Cache/NoLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8662e3ccbb3b34f1f9b9fa7f21f33d3df433cf62 --- /dev/null +++ b/tests/Integration/Cache/NoLockTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Illuminate\Tests\Integration\Cache; + +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Orchestra\Testbench\TestCase; + +class NoLockTest extends TestCase +{ + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('cache.default', 'null'); + + $app['config']->set('cache.stores', [ + 'null' => [ + 'driver' => 'null', + ], + ]); + } + + public function testLocksCanAlwaysBeAcquiredAndReleased() + { + Cache::lock('foo')->forceRelease(); + + $lock = Cache::lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertTrue(Cache::lock('foo', 10)->get()); + $this->assertTrue($lock->release()); + $this->assertTrue($lock->release()); + } + + public function testLocksCanBlockForSeconds() + { + Carbon::setTestNow(); + + Cache::lock('foo')->forceRelease(); + $this->assertSame('taylor', Cache::lock('foo', 10)->block(1, function () { + return 'taylor'; + })); + + Cache::lock('foo')->forceRelease(); + $this->assertTrue(Cache::lock('foo', 10)->block(1)); + } +} diff --git a/tests/Integration/Cache/PhpRedisCacheLockTest.php b/tests/Integration/Cache/PhpRedisCacheLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0ca4ea85f96070ffe88950b7739c1d35e0ae5958 --- /dev/null +++ b/tests/Integration/Cache/PhpRedisCacheLockTest.php @@ -0,0 +1,294 @@ +<?php + +namespace Illuminate\Tests\Integration\Cache; + +use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; +use Illuminate\Support\Facades\Cache; +use Orchestra\Testbench\TestCase; +use Redis; + +class PhpRedisCacheLockTest extends TestCase +{ + use InteractsWithRedis; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpRedis(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownRedis(); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithoutSerializationAndCompression() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithPhpSerialization() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithJsonSerialization() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithIgbinarySerialization() + { + if (! defined('Redis::SERIALIZER_IGBINARY')) { + $this->markTestSkipped('Redis extension is not configured to support the igbinary serializer.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_IGBINARY); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithMsgpackSerialization() + { + if (! defined('Redis::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('Redis extension is not configured to support the msgpack serializer.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_MSGPACK); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + /** + * @requires extension lzf + */ + public function testRedisLockCanBeAcquiredAndReleasedWithLzfCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + /** + * @requires extension zstd + */ + public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression() + { + if (! defined('Redis::COMPRESSION_ZSTD')) { + $this->markTestSkipped('Redis extension is not configured to support the zstd compression.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_ZSTD); + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_DEFAULT); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_MIN); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_MAX); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + /** + * @requires extension lz4 + */ + public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression() + { + if (! defined('Redis::COMPRESSION_LZ4')) { + $this->markTestSkipped('Redis extension is not configured to support the lz4 compression.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZ4); + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 1); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 3); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 12); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + /** + * @requires extension Lzf + */ + public function testRedisLockCanBeAcquiredAndReleasedWithSerializationAndCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } +} diff --git a/tests/Integration/Cache/RedisCacheLockTest.php b/tests/Integration/Cache/RedisCacheLockTest.php index fa3a9d0b206989f7d60b5eab33f792f6e7f47f05..c131c0c23c2e4639b1e1f03d481471b41f0eea8f 100644 --- a/tests/Integration/Cache/RedisCacheLockTest.php +++ b/tests/Integration/Cache/RedisCacheLockTest.php @@ -8,9 +8,6 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RedisCacheLockTest extends TestCase { use InteractsWithRedis; @@ -44,6 +41,13 @@ class RedisCacheLockTest extends TestCase Cache::store('redis')->lock('foo')->release(); } + public function testRedisLockCanHaveASeparateConnection() + { + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + $this->assertSame('default', Cache::store('redis')->lock('foo')->getConnectionName()); + } + public function testRedisLocksCanBlockForSeconds() { Carbon::setTestNow(); diff --git a/tests/Integration/Cache/RedisStoreTest.php b/tests/Integration/Cache/RedisStoreTest.php index fd2995b7e7374616b19da9e6bf2f23f294e9ee0c..ad89de93afd291dc82fb7a6f1723f88b180baa36 100644 --- a/tests/Integration/Cache/RedisStoreTest.php +++ b/tests/Integration/Cache/RedisStoreTest.php @@ -4,12 +4,9 @@ namespace Illuminate\Tests\Integration\Cache; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; use Illuminate\Support\Facades\Cache; -use Illuminate\Tests\Integration\IntegrationTest; +use Orchestra\Testbench\TestCase; -/** - * @group integration - */ -class RedisStoreTest extends IntegrationTest +class RedisStoreTest extends TestCase { use InteractsWithRedis; diff --git a/tests/Integration/Console/CallbackSchedulingTest.php b/tests/Integration/Console/CallbackSchedulingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9ad046b1931b0492c5d653d64fac9ec2716c23ae --- /dev/null +++ b/tests/Integration/Console/CallbackSchedulingTest.php @@ -0,0 +1,140 @@ +<?php + +namespace Illuminate\Tests\Integration\Console; + +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository; +use Illuminate\Console\Events\ScheduledTaskFailed; +use Illuminate\Console\Scheduling\CacheEventMutex; +use Illuminate\Console\Scheduling\CacheSchedulingMutex; +use Illuminate\Console\Scheduling\EventMutex; +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Console\Scheduling\SchedulingMutex; +use Illuminate\Container\Container; +use Illuminate\Contracts\Cache\Factory; +use Illuminate\Contracts\Events\Dispatcher; +use Orchestra\Testbench\TestCase; +use RuntimeException; + +class CallbackSchedulingTest extends TestCase +{ + protected $log = []; + + protected function setUp(): void + { + parent::setUp(); + + $cache = new class implements Factory + { + public $store; + + public function __construct() + { + $this->store = new Repository(new ArrayStore(true)); + } + + public function store($name = null) + { + return $this->store; + } + }; + + $container = Container::getInstance(); + + $container->instance(EventMutex::class, new CacheEventMutex($cache)); + $container->instance(SchedulingMutex::class, new CacheSchedulingMutex($cache)); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + parent::tearDown(); + } + + /** + * @dataProvider executionProvider + */ + public function testExecutionOrder($background) + { + $event = $this->app->make(Schedule::class) + ->call($this->logger('call')) + ->after($this->logger('after 1')) + ->before($this->logger('before 1')) + ->after($this->logger('after 2')) + ->before($this->logger('before 2')); + + if ($background) { + $event->runInBackground(); + } + + $this->artisan('schedule:run'); + + $this->assertLogged('before 1', 'before 2', 'call', 'after 1', 'after 2'); + } + + public function testExceptionHandlingInCallback() + { + $event = $this->app->make(Schedule::class) + ->call($this->logger('call')) + ->name('test-event') + ->withoutOverlapping(); + + // Set up "before" and "after" hooks to ensure they're called + $event->before($this->logger('before'))->after($this->logger('after')); + + // Register a hook to validate that the mutex was initially created + $mutexWasCreated = false; + $event->before(function () use (&$mutexWasCreated, $event) { + $mutexWasCreated = $event->mutex->exists($event); + }); + + // We'll trigger an exception in an "after" hook to test exception handling + $event->after(function () { + throw new RuntimeException; + }); + + // Because exceptions are caught by the ScheduleRunCommand, we need to listen for + // the "failed" event to check whether our exception was actually thrown + $failed = false; + $this->app->make(Dispatcher::class) + ->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $failure) use (&$failed, $event) { + if ($failure->task === $event) { + $failed = true; + } + }); + + $this->artisan('schedule:run'); + + // Hooks and execution should happn in correct order + $this->assertLogged('before', 'call', 'after'); + + // Our exception should have resulted in a failure event + $this->assertTrue($failed); + + // Validate that the mutex was originally created, but that it's since + // been removed (even though an exception was thrown) + $this->assertTrue($mutexWasCreated); + $this->assertFalse($event->mutex->exists($event)); + } + + public function executionProvider() + { + return [ + 'Foreground' => [false], + 'Background' => [true], + ]; + } + + protected function logger($message) + { + return function () use ($message) { + $this->log[] = $message; + }; + } + + protected function assertLogged(...$message) + { + $this->assertEquals($message, $this->log); + } +} diff --git a/tests/Integration/Console/CommandSchedulingTest.php b/tests/Integration/Console/CommandSchedulingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d29d2ae14500059e7e79cb9e34703c4ac75a305b --- /dev/null +++ b/tests/Integration/Console/CommandSchedulingTest.php @@ -0,0 +1,209 @@ +<?php + +namespace Illuminate\Tests\Integration\Console; + +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Str; +use Orchestra\Testbench\TestCase; + +class CommandSchedulingTest extends TestCase +{ + /** + * Each run of this test is assigned a random ID to ensure that separate runs + * do not interfere with each other. + * + * @var string + */ + protected $id; + + /** + * The path to the file that execution logs will be written to. + * + * @var string + */ + protected $logfile; + + /** + * Just in case Testbench starts to ship an `artisan` script, we'll check and save a backup. + * + * @var string|null + */ + protected $originalArtisan; + + /** + * The Filesystem instance for writing stubs and logs. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $fs; + + protected function setUp(): void + { + parent::setUp(); + + $this->fs = new Filesystem; + + $this->id = Str::random(); + $this->logfile = storage_path("logs/command_scheduling_test_{$this->id}.log"); + + $this->writeArtisanScript(); + } + + protected function tearDown(): void + { + $this->fs->delete($this->logfile); + $this->fs->delete(base_path('artisan')); + + if (! is_null($this->originalArtisan)) { + $this->fs->put(base_path('artisan'), $this->originalArtisan); + } + + parent::tearDown(); + } + + /** + * @dataProvider executionProvider + */ + public function testExecutionOrder($background) + { + $event = $this->app->make(Schedule::class) + ->command("test:{$this->id}") + ->onOneServer() + ->after(function () { + $this->fs->append($this->logfile, "after\n"); + }) + ->before(function () { + $this->fs->append($this->logfile, "before\n"); + }); + + if ($background) { + $event->runInBackground(); + } + + // We'll trigger the scheduler three times to simulate multiple servers + $this->artisan('schedule:run'); + $this->artisan('schedule:run'); + $this->artisan('schedule:run'); + + if ($background) { + // Since our command is running in a separate process, we need to wait + // until it has finished executing before running our assertions. + $this->waitForLogMessages('before', 'handled', 'after'); + } + + $this->assertLogged('before', 'handled', 'after'); + } + + public function executionProvider() + { + return [ + 'Foreground' => [false], + 'Background' => [true], + ]; + } + + protected function waitForLogMessages(...$messages) + { + $tries = 0; + $sleep = 100000; // 100K microseconds = 0.1 second + $limit = 50; // 0.1s * 50 = 5 second wait limit + + do { + $log = $this->fs->get($this->logfile); + + if (Str::containsAll($log, $messages)) { + return; + } + + $tries++; + usleep($sleep); + } while ($tries < $limit); + } + + protected function assertLogged(...$messages) + { + $log = trim($this->fs->get($this->logfile)); + + $this->assertEquals(implode("\n", $messages), $log); + } + + protected function writeArtisanScript() + { + $path = base_path('artisan'); + + // Save existing artisan script if there is one + if ($this->fs->exists($path)) { + $this->originalArtisan = $this->fs->get($path); + } + + $thisFile = __FILE__; + $logfile = var_export($this->logfile, true); + + $script = <<<PHP +#!/usr/bin/env php +<?php + +// This is a custom artisan script made specifically for: +// +// {$thisFile} +// +// It should be automatically cleaned up when the tests have finished executing. +// If you are seeing this file, an unexpected error must have occurred. Please +// manually remove it. + +define('LARAVEL_START', microtime(true)); + +require __DIR__.'/../../../autoload.php'; + +\$app = require_once __DIR__.'/bootstrap/app.php'; +\$kernel = \$app->make(Illuminate\Contracts\Console\Kernel::class); + +// Here is our custom command for the test +class CommandSchedulingTestCommand_{$this->id} extends Illuminate\Console\Command +{ + protected \$signature = 'test:{$this->id}'; + + public function handle() + { + \$logfile = {$logfile}; + (new Illuminate\Filesystem\Filesystem)->append(\$logfile, "handled\\n"); + } +} + +// Register command with Kernel +Illuminate\Console\Application::starting(function (\$artisan) { + \$artisan->add(new CommandSchedulingTestCommand_{$this->id}); +}); + +// Add command to scheduler so that the after() callback is trigger in our spawned process +Illuminate\Foundation\Application::getInstance() + ->booted(function (\$app) { + \$app->resolving(Illuminate\Console\Scheduling\Schedule::class, function(\$schedule) { + \$fs = new Illuminate\Filesystem\Filesystem; + \$schedule->command("test:{$this->id}") + ->after(function() use (\$fs) { + \$logfile = {$logfile}; + \$fs->append(\$logfile, "after\\n"); + }) + ->before(function() use (\$fs) { + \$logfile = {$logfile}; + \$fs->append(\$logfile, "before\\n"); + }); + }); + }); + +\$status = \$kernel->handle( + \$input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +\$kernel->terminate(\$input, \$status); + +exit(\$status); + +PHP; + + $this->fs->put($path, $script); + } +} diff --git a/tests/Integration/Console/JobSchedulingTest.php b/tests/Integration/Console/JobSchedulingTest.php index 7f2c51d52aaae8b98da999c2bae94863b6592aab..faa89f971bd7dbac194bfdb9894724073b0574fc 100644 --- a/tests/Integration/Console/JobSchedulingTest.php +++ b/tests/Integration/Console/JobSchedulingTest.php @@ -4,7 +4,6 @@ namespace Illuminate\Tests\Integration\Console; use Illuminate\Bus\Queueable; use Illuminate\Console\Scheduling\Schedule; -use Illuminate\Container\Container; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Queue; @@ -16,7 +15,7 @@ class JobSchedulingTest extends TestCase { Queue::fake(); - /** @var Schedule $scheduler */ + /** @var \Illuminate\Console\Scheduling\Schedule $scheduler */ $scheduler = $this->app->make(Schedule::class); // all job names were set to an empty string so that the registered shutdown function in CallbackEvent does nothing @@ -44,7 +43,7 @@ class JobSchedulingTest extends TestCase { Queue::fake(); - /** @var Schedule $scheduler */ + /** @var \Illuminate\Console\Scheduling\Schedule $scheduler */ $scheduler = $this->app->make(Schedule::class); // all job names were set to an empty string so that the registered shutdown function in CallbackEvent does nothing diff --git a/tests/Integration/Console/Scheduling/CallbackEventTest.php b/tests/Integration/Console/Scheduling/CallbackEventTest.php new file mode 100644 index 0000000000000000000000000000000000000000..012e348fbf6d1787e679aa07392effc365bad561 --- /dev/null +++ b/tests/Integration/Console/Scheduling/CallbackEventTest.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Tests\Integration\Console\Scheduling; + +use Exception; +use Illuminate\Console\Scheduling\CallbackEvent; +use Illuminate\Console\Scheduling\EventMutex; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class CallbackEventTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testDefaultResultIsSuccess() + { + $success = null; + + $event = (new CallbackEvent(m::mock(EventMutex::class), function () { + }))->onSuccess(function () use (&$success) { + $success = true; + })->onFailure(function () use (&$success) { + $success = false; + }); + + $event->run($this->app); + + $this->assertTrue($success); + } + + public function testFalseResponseIsFailure() + { + $success = null; + + $event = (new CallbackEvent(m::mock(EventMutex::class), function () { + return false; + }))->onSuccess(function () use (&$success) { + $success = true; + })->onFailure(function () use (&$success) { + $success = false; + }); + + $event->run($this->app); + + $this->assertFalse($success); + } + + public function testExceptionIsFailure() + { + $success = null; + + $event = (new CallbackEvent(m::mock(EventMutex::class), function () { + throw new Exception; + }))->onSuccess(function () use (&$success) { + $success = true; + })->onFailure(function () use (&$success) { + $success = false; + }); + + try { + $event->run($this->app); + } catch (Exception $e) { + } + + $this->assertFalse($success); + } + + public function testExceptionBubbles() + { + $event = new CallbackEvent(m::mock(EventMutex::class), function () { + throw new Exception; + }); + + $this->expectException(Exception::class); + + $event->run($this->app); + } +} diff --git a/tests/Integration/Console/Scheduling/EventPingTest.php b/tests/Integration/Console/Scheduling/EventPingTest.php index 4d69bd11784c1d6353f52256c29356968bc052ed..04c4774d3fc26ad09b75e2da38e34c4baf9a80b4 100644 --- a/tests/Integration/Console/Scheduling/EventPingTest.php +++ b/tests/Integration/Console/Scheduling/EventPingTest.php @@ -14,9 +14,6 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Mockery as m; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class EventPingTest extends TestCase { protected function tearDown(): void diff --git a/tests/Integration/Console/UniqueJobSchedulingTest.php b/tests/Integration/Console/UniqueJobSchedulingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9a0f88846ae74ef8725c97cb63fcf8fe079a2c62 --- /dev/null +++ b/tests/Integration/Console/UniqueJobSchedulingTest.php @@ -0,0 +1,63 @@ +<?php + +namespace Illuminate\Tests\Integration\Console; + +use Illuminate\Bus\Queueable; +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Queue; +use Orchestra\Testbench\TestCase; + +class UniqueJobSchedulingTest extends TestCase +{ + public function testJobsPushedToQueue() + { + Queue::fake(); + $this->dispatch( + TestJob::class, + TestJob::class, + TestJob::class, + TestJob::class + ); + + Queue::assertPushed(TestJob::class, 4); + } + + public function testUniqueJobsPushedToQueue() + { + Queue::fake(); + $this->dispatch( + UniqueTestJob::class, + UniqueTestJob::class, + UniqueTestJob::class, + UniqueTestJob::class + ); + + Queue::assertPushed(UniqueTestJob::class, 1); + } + + private function dispatch(...$jobs) + { + /** @var \Illuminate\Console\Scheduling\Schedule $scheduler */ + $scheduler = $this->app->make(Schedule::class); + foreach ($jobs as $job) { + $scheduler->job($job)->name('')->everyMinute(); + } + $events = $scheduler->events(); + foreach ($events as $event) { + $event->run($this->app); + } + } +} + +class TestJob implements ShouldQueue +{ + use InteractsWithQueue, Queueable, Dispatchable; +} + +class UniqueTestJob extends TestJob implements ShouldBeUnique +{ +} diff --git a/tests/Integration/Cookie/CookieTest.php b/tests/Integration/Cookie/CookieTest.php index 299f22dc8f9563d1c8894a30aa1b1c7275a6826a..543d0ffd4bfd7009f9b83c16f764d3b89ee0f04b 100644 --- a/tests/Integration/Cookie/CookieTest.php +++ b/tests/Integration/Cookie/CookieTest.php @@ -12,9 +12,6 @@ use Illuminate\Support\Str; use Mockery; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class CookieTest extends TestCase { public function test_cookie_is_sent_back_with_proper_expire_time_when_should_expire_on_close() diff --git a/tests/Integration/Database/ConfigureCustomDoctrineTypeTest.php b/tests/Integration/Database/ConfigureCustomDoctrineTypeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f0f36deb4e36f0988275bd4c328836064ed25502 --- /dev/null +++ b/tests/Integration/Database/ConfigureCustomDoctrineTypeTest.php @@ -0,0 +1,112 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\SchemaTest; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type; +use Illuminate\Database\Grammar; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class ConfigureCustomDoctrineTypeTest extends DatabaseTestCase +{ + protected function defineEnvironment($app) + { + $app['config']['database.connections.sqlite.database'] = ':memory:'; + $app['config']['database.dbal.types'] = [ + 'bit' => MySQLBitType::class, + 'xml' => PostgresXmlType::class, + ]; + } + + public function testRegisterCustomDoctrineTypesWithNonDefaultDatabaseConnections() + { + $this->assertTrue( + DB::connection() + ->getDoctrineSchemaManager() + ->getDatabasePlatform() + ->hasDoctrineTypeMappingFor('xml') + ); + + // Custom type mappings are registered for a connection when it's created, + // this is not the default connection but it has the custom type mappings + $this->assertTrue( + DB::connection('sqlite') + ->getDoctrineSchemaManager() + ->getDatabasePlatform() + ->hasDoctrineTypeMappingFor('xml') + ); + } + + public function testRenameConfiguredCustomDoctrineColumnTypeWithPostgres() + { + if ($this->driver !== 'pgsql') { + $this->markTestSkipped('Test requires a Postgres connection.'); + } + + Grammar::macro('typeXml', function () { + return 'xml'; + }); + + Schema::create('test', function (Blueprint $table) { + $table->addColumn('xml', 'test_column'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->renameColumn('test_column', 'renamed_column'); + }); + + $this->assertFalse(Schema::hasColumn('test', 'test_column')); + $this->assertTrue(Schema::hasColumn('test', 'renamed_column')); + } + + public function testRenameConfiguredCustomDoctrineColumnTypeWithMysql() + { + if ($this->driver !== 'mysql') { + $this->markTestSkipped('Test requires a MySQL connection.'); + } + + Grammar::macro('typeBit', function () { + return 'bit'; + }); + + Schema::create('test', function (Blueprint $table) { + $table->addColumn('bit', 'test_column'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->renameColumn('test_column', 'renamed_column'); + }); + + $this->assertFalse(Schema::hasColumn('test', 'test_column')); + $this->assertTrue(Schema::hasColumn('test', 'renamed_column')); + } +} + +class PostgresXmlType extends Type +{ + public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) + { + return 'xml'; + } + + public function getName() + { + return 'xml'; + } +} + +class MySQLBitType extends Type +{ + public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) + { + return 'bit'; + } + + public function getName() + { + return 'bit'; + } +} diff --git a/tests/Integration/Database/DBAL/TimestampTypeTest.php b/tests/Integration/Database/DBAL/TimestampTypeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b3c9fc3e2875a42cdc9c223821ba98a7fe4d7809 --- /dev/null +++ b/tests/Integration/Database/DBAL/TimestampTypeTest.php @@ -0,0 +1,62 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\DBAL; + +use Illuminate\Database\DBAL\TimestampType; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class TimestampTypeTest extends DatabaseTestCase +{ + protected function defineEnvironment($app) + { + $app['config']['database.dbal.types'] = [ + 'timestamp' => TimestampType::class, + ]; + } + + public function testRegisterTimestampTypeOnConnection() + { + $this->assertTrue( + $this->app['db']->connection() + ->getDoctrineSchemaManager() + ->getDatabasePlatform() + ->hasDoctrineTypeMappingFor('timestamp') + ); + } + + public function testChangeDatetimeColumnToTimestampColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->addColumn('datetime', 'datetime_to_timestamp'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->timestamp('datetime_to_timestamp')->nullable(true)->change(); + }); + + $this->assertTrue(Schema::hasColumn('test', 'datetime_to_timestamp')); + // Only Postgres and MySQL actually have a timestamp type + in_array($this->driver, ['pgsql', 'mysql']) + ? $this->assertSame('timestamp', Schema::getColumnType('test', 'datetime_to_timestamp')) + : $this->assertSame('datetime', Schema::getColumnType('test', 'datetime_to_timestamp')); + } + + public function testChangeTimestampColumnToDatetimeColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->addColumn('timestamp', 'timestamp_to_datetime'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->dateTime('timestamp_to_datetime')->nullable(true)->change(); + }); + + $this->assertTrue(Schema::hasColumn('test', 'timestamp_to_datetime')); + // Postgres only has a timestamp type + $this->driver === 'pgsql' + ? $this->assertSame('timestamp', Schema::getColumnType('test', 'timestamp_to_datetime')) + : $this->assertSame('datetime', Schema::getColumnType('test', 'timestamp_to_datetime')); + } +} diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..87ee67cdf1c17fafd21cd839955bd8b464ef0cd9 --- /dev/null +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Casts\AsArrayObject; +use Illuminate\Database\Eloquent\Casts\AsCollection; +use Illuminate\Database\Eloquent\Casts\AsStringable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; + +class DatabaseCustomCastsTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_eloquent_model_with_custom_casts', function (Blueprint $table) { + $table->increments('id'); + $table->text('array_object'); + $table->text('collection'); + $table->string('stringable'); + $table->timestamps(); + }); + } + + public function test_custom_casting() + { + $model = new TestEloquentModelWithCustomCasts; + + $model->array_object = ['name' => 'Taylor']; + $model->collection = collect(['name' => 'Taylor']); + $model->stringable = Str::of('Taylor'); + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals(['name' => 'Taylor'], $model->array_object->toArray()); + $this->assertEquals(['name' => 'Taylor'], $model->collection->toArray()); + $this->assertEquals('Taylor', (string) $model->stringable); + + $model->array_object['age'] = 34; + $model->array_object['meta']['title'] = 'Developer'; + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals([ + 'name' => 'Taylor', + 'age' => 34, + 'meta' => ['title' => 'Developer'], + ], $model->array_object->toArray()); + } +} + +class TestEloquentModelWithCustomCasts extends Model +{ + /** + * The attributes that aren't mass assignable. + * + * @var string[] + */ + protected $guarded = []; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'array_object' => AsArrayObject::class, + 'collection' => AsCollection::class, + 'stringable' => AsStringable::class, + ]; +} diff --git a/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php b/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..aa17d8acedec0afeabb646d4b620d0a98fabdb0f --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php @@ -0,0 +1,268 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Broadcasting\BroadcastEvent; +use Illuminate\Contracts\Broadcasting\Broadcaster; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; +use Illuminate\Database\Eloquent\BroadcastableModelEventOccurred; +use Illuminate\Database\Eloquent\BroadcastsEvents; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Mockery as m; + +class DatabaseEloquentBroadcastingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_eloquent_broadcasting_users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function testBasicBroadcasting() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser; + $model->name = 'Taylor'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && count($event->broadcastOn()) === 1 + && $event->model->name === 'Taylor' + && $event->broadcastOn()[0]->name == "private-Illuminate.Tests.Integration.Database.TestEloquentBroadcastUser.{$event->model->id}"; + }); + } + + public function testChannelRouteFormatting() + { + $model = new TestEloquentBroadcastUser; + + $this->assertEquals('Illuminate.Tests.Integration.Database.TestEloquentBroadcastUser.{testEloquentBroadcastUser}', $model->broadcastChannelRoute()); + } + + public function testBroadcastingOnModelTrashing() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new SoftDeletableTestEloquentBroadcastUser; + $model->name = 'Bean'; + $model->saveQuietly(); + + $model->delete(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof SoftDeletableTestEloquentBroadcastUser + && $event->event() == 'trashed' + && count($event->broadcastOn()) === 1 + && $event->model->name === 'Bean' + && $event->broadcastOn()[0]->name == "private-Illuminate.Tests.Integration.Database.SoftDeletableTestEloquentBroadcastUser.{$event->model->id}"; + }); + } + + public function testBroadcastingForSpecificEventsOnly() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserOnSpecificEventsOnly; + $model->name = 'James'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserOnSpecificEventsOnly + && $event->event() == 'created' + && count($event->broadcastOn()) === 1 + && $event->model->name === 'James' + && $event->broadcastOn()[0]->name == "private-Illuminate.Tests.Integration.Database.TestEloquentBroadcastUserOnSpecificEventsOnly.{$event->model->id}"; + }); + + $model->name = 'Graham'; + $model->save(); + + Event::assertNotDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserOnSpecificEventsOnly + && $event->model->name === 'Graham' + && $event->event() == 'updated'; + }); + } + + public function testBroadcastNameDefault() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser; + $model->name = 'Mohamed'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && $event->model->name === 'Mohamed' + && $event->broadcastAs() === 'TestEloquentBroadcastUserCreated' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'TestEloquentBroadcastUserCreated'; + }); + }); + } + + public function testBroadcastNameCanBeDefined() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserWithSpecificBroadcastName; + $model->name = 'Nuno'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastName + && $event->model->name === 'Nuno' + && $event->broadcastAs() === 'foo' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'foo'; + }); + }); + + $model->name = 'Dries'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastName + && $event->model->name === 'Dries' + && $event->broadcastAs() === 'TestEloquentBroadcastUserWithSpecificBroadcastNameUpdated' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'TestEloquentBroadcastUserWithSpecificBroadcastNameUpdated'; + }); + }); + } + + public function testBroadcastPayloadDefault() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser; + $model->name = 'Nuno'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && $event->model->name === 'Nuno' + && is_null($event->broadcastWith()) + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['model', 'connection', 'queue', 'socket']); + }); + }); + } + + public function testBroadcastPayloadCanBeDefined() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserWithSpecificBroadcastPayload; + $model->name = 'Dries'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastPayload + && $event->model->name === 'Dries' + && $event->broadcastWith() === ['foo' => 'bar'] + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['foo', 'socket']); + }); + }); + + $model->name = 'Graham'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastPayload + && $event->model->name === 'Graham' + && is_null($event->broadcastWith()) + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['model', 'connection', 'queue', 'socket']); + }); + }); + } + + private function assertHandldedBroadcastableEvent(BroadcastableModelEventOccurred $event, \Closure $closure) + { + $broadcaster = m::mock(Broadcaster::class); + $broadcaster->shouldReceive('broadcast')->once() + ->withArgs(function (array $channels, string $eventName, array $payload) use ($closure) { + return $closure($channels, $eventName, $payload); + }); + + $manager = m::mock(BroadcastingFactory::class); + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + (new BroadcastEvent($event))->handle($manager); + + return true; + } +} + +class TestEloquentBroadcastUser extends Model +{ + use BroadcastsEvents; + + protected $table = 'test_eloquent_broadcasting_users'; +} + +class SoftDeletableTestEloquentBroadcastUser extends Model +{ + use BroadcastsEvents, SoftDeletes; + + protected $table = 'test_eloquent_broadcasting_users'; +} + +class TestEloquentBroadcastUserOnSpecificEventsOnly extends Model +{ + use BroadcastsEvents; + + protected $table = 'test_eloquent_broadcasting_users'; + + public function broadcastOn($event) + { + switch ($event) { + case 'created': + return [$this]; + } + } +} + +class TestEloquentBroadcastUserWithSpecificBroadcastName extends Model +{ + use BroadcastsEvents; + + protected $table = 'test_eloquent_broadcasting_users'; + + public function broadcastAs($event) + { + switch ($event) { + case 'created': + return 'foo'; + } + } +} + +class TestEloquentBroadcastUserWithSpecificBroadcastPayload extends Model +{ + use BroadcastsEvents; + + protected $table = 'test_eloquent_broadcasting_users'; + + public function broadcastWith($event) + { + switch ($event) { + case 'created': + return ['foo' => 'bar']; + } + } +} diff --git a/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..35a12654eb7cf7cf35814fc1ae170933d18636f7 --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php @@ -0,0 +1,426 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Date; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; + +class DatabaseEloquentModelAttributeCastingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_eloquent_model_with_custom_casts', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + }); + } + + public function testBasicCustomCasting() + { + $model = new TestEloquentModelWithAttributeCast; + $model->uppercase = 'taylor'; + + $this->assertSame('TAYLOR', $model->uppercase); + $this->assertSame('TAYLOR', $model->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $model->toArray()['uppercase']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame('TAYLOR', $unserializedModel->uppercase); + $this->assertSame('TAYLOR', $unserializedModel->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $unserializedModel->toArray()['uppercase']); + + $model->syncOriginal(); + $model->uppercase = 'dries'; + $this->assertSame('TAYLOR', $model->getOriginal('uppercase')); + + $model = new TestEloquentModelWithAttributeCast; + $model->uppercase = 'taylor'; + $model->syncOriginal(); + $model->uppercase = 'dries'; + $model->getOriginal(); + + $this->assertSame('DRIES', $model->uppercase); + + $model = new TestEloquentModelWithAttributeCast; + + $model->address = $address = new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'); + $address->lineOne = '117 Spencer St.'; + $this->assertSame('117 Spencer St.', $model->getAttributes()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + $this->assertSame('My Childhood House', $model->address->lineTwo); + + $this->assertSame('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertSame('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $this->assertSame('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertSame('My Childhood House', json_decode($model->toJson(), true)['address_line_two']); + + $model->address = null; + + $this->assertNull($model->toArray()['address_line_one']); + $this->assertNull($model->toArray()['address_line_two']); + + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + $model->options = ['foo' => 'bar']; + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + + $this->assertSame(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + + $model = new TestEloquentModelWithAttributeCast(['options' => []]); + $model->syncOriginal(); + $model->options = ['foo' => 'bar']; + $this->assertTrue($model->isDirty('options')); + + $model = new TestEloquentModelWithAttributeCast; + $model->birthday_at = now(); + $this->assertIsString($model->toArray()['birthday_at']); + } + + public function testGetOriginalWithCastValueObjects() + { + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal('address')->lineOne); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = null; + + $this->assertNull($model->address); + $this->assertInstanceOf(AttributeCastAddress::class, $model->getOriginal('address')); + $this->assertNull($model->address); + } + + public function testOneWayCasting() + { + $model = new TestEloquentModelWithAttributeCast; + + $this->assertNull($model->password); + + $model->password = 'secret'; + + $this->assertEquals(hash('sha256', 'secret'), $model->password); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->password); + + $model->password = 'secret2'; + + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + } + + public function testSettingRawAttributesClearsTheCastCache() + { + $model = new TestEloquentModelWithAttributeCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + } + + public function testCastsThatOnlyHaveGetterDoNotPeristAnythingToModelOnSave() + { + $model = new TestEloquentModelWithAttributeCast; + + $model->virtual; + + $model->getAttributes(); + + $this->assertTrue(empty($model->getDirty())); + } + + public function testCastsThatOnlyHaveGetterThatReturnsPrimitivesAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = null; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualString); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreCached() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualObject; + + foreach (range(0, 10) as $ignored) { + $this->assertSame($previous, $previous = $model->virtualObject); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreCached() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualDateTime; + + foreach (range(0, 10) as $ignored) { + $this->assertSame($previous, $previous = $model->virtualDateTime); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualObjectWithoutCaching; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualObjectWithoutCaching); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualDateTimeWithoutCaching; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualDateTimeWithoutCaching); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreNotCachedFluent() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualObjectWithoutCachingFluent; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualObjectWithoutCachingFluent); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreNotCachedFluent() + { + $model = new TestEloquentModelWithAttributeCast; + + $previous = $model->virtualDateTimeWithoutCachingFluent; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualDateTimeWithoutCachingFluent); + } + } +} + +class TestEloquentModelWithAttributeCast extends Model +{ + /** + * The attributes that aren't mass assignable. + * + * @var string[] + */ + protected $guarded = []; + + public function uppercase(): Attribute + { + return new Attribute( + function ($value) { + return strtoupper($value); + }, + function ($value) { + return strtoupper($value); + } + ); + } + + public function address(): Attribute + { + return new Attribute( + function ($value, $attributes) { + if (is_null($attributes['address_line_one'])) { + return; + } + + return new AttributeCastAddress($attributes['address_line_one'], $attributes['address_line_two']); + }, + function ($value) { + if (is_null($value)) { + return [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + } + + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } + ); + } + + public function options(): Attribute + { + return new Attribute( + function ($value) { + return json_decode($value, true); + }, + function ($value) { + return json_encode($value); + } + ); + } + + public function birthdayAt(): Attribute + { + return new Attribute( + function ($value) { + return Carbon::parse($value); + }, + function ($value) { + return $value->format('Y-m-d'); + } + ); + } + + public function password(): Attribute + { + return new Attribute(null, function ($value) { + return hash('sha256', $value); + }); + } + + public function virtual(): Attribute + { + return new Attribute( + function () { + return collect(); + } + ); + } + + public function virtualString(): Attribute + { + return new Attribute( + function () { + return Str::random(10); + } + ); + } + + public function virtualObject(): Attribute + { + return new Attribute( + function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + } + ); + } + + public function virtualDateTime(): Attribute + { + return new Attribute( + function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + } + ); + } + + public function virtualObjectWithoutCachingFluent(): Attribute + { + return (new Attribute( + function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + } + ))->withoutObjectCaching(); + } + + public function virtualDateTimeWithoutCachingFluent(): Attribute + { + return (new Attribute( + function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + } + ))->withoutObjectCaching(); + } + + public function virtualObjectWithoutCaching(): Attribute + { + return Attribute::get(function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + })->withoutObjectCaching(); + } + + public function virtualDateTimeWithoutCaching(): Attribute + { + return Attribute::get(function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + })->withoutObjectCaching(); + } +} + +class AttributeCastAddress +{ + public $lineOne; + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +} diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0ba885050d8234ec48473a67ee9c781d35c4912d --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -0,0 +1,510 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Contracts\Database\Eloquent\Castable; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; +use Illuminate\Contracts\Database\Eloquent\DeviatesCastableAttributes; +use Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes; +use Illuminate\Database\Eloquent\InvalidCastException; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Schema; + +class DatabaseEloquentModelCustomCastingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_eloquent_model_with_custom_casts', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->decimal('price'); + }); + } + + public function testBasicCustomCasting() + { + $model = new TestEloquentModelWithCustomCast; + $model->uppercase = 'taylor'; + + $this->assertSame('TAYLOR', $model->uppercase); + $this->assertSame('TAYLOR', $model->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $model->toArray()['uppercase']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame('TAYLOR', $unserializedModel->uppercase); + $this->assertSame('TAYLOR', $unserializedModel->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $unserializedModel->toArray()['uppercase']); + + $model->syncOriginal(); + $model->uppercase = 'dries'; + $this->assertSame('TAYLOR', $model->getOriginal('uppercase')); + + $model = new TestEloquentModelWithCustomCast; + $model->uppercase = 'taylor'; + $model->syncOriginal(); + $model->uppercase = 'dries'; + $model->getOriginal(); + + $this->assertSame('DRIES', $model->uppercase); + + $model = new TestEloquentModelWithCustomCast; + + $model->address = $address = new Address('110 Kingsbrook St.', 'My Childhood House'); + $address->lineOne = '117 Spencer St.'; + $this->assertSame('117 Spencer St.', $model->getAttributes()['address_line_one']); + + $model = new TestEloquentModelWithCustomCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + $this->assertSame('My Childhood House', $model->address->lineTwo); + + $this->assertSame('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertSame('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $this->assertSame('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertSame('My Childhood House', json_decode($model->toJson(), true)['address_line_two']); + + $model->address = null; + + $this->assertNull($model->toArray()['address_line_one']); + $this->assertNull($model->toArray()['address_line_two']); + + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + $model->options = ['foo' => 'bar']; + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + + $this->assertSame(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + + $model = new TestEloquentModelWithCustomCast(['options' => []]); + $model->syncOriginal(); + $model->options = ['foo' => 'bar']; + $this->assertTrue($model->isDirty('options')); + + $model = new TestEloquentModelWithCustomCast; + $model->birthday_at = now(); + $this->assertIsString($model->toArray()['birthday_at']); + } + + public function testGetOriginalWithCastValueObjects() + { + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new Address('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal('address')->lineOne); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new Address('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = null; + + $this->assertNull($model->address); + $this->assertInstanceOf(Address::class, $model->getOriginal('address')); + $this->assertNull($model->address); + } + + public function testDeviableCasts() + { + $model = new TestEloquentModelWithCustomCast; + $model->price = '123.456'; + $model->save(); + + $model->increment('price', '530.865'); + + $this->assertSame((new Decimal('654.321'))->getValue(), $model->price->getValue()); + + $model->decrement('price', '333.333'); + + $this->assertSame((new Decimal('320.988'))->getValue(), $model->price->getValue()); + } + + public function testSerializableCasts() + { + $model = new TestEloquentModelWithCustomCast; + $model->price = '123.456'; + + $expectedValue = (new Decimal('123.456'))->getValue(); + + $this->assertSame($expectedValue, $model->price->getValue()); + $this->assertSame('123.456', $model->getAttributes()['price']); + $this->assertSame('123.456', $model->toArray()['price']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame($expectedValue, $unserializedModel->price->getValue()); + $this->assertSame('123.456', $unserializedModel->getAttributes()['price']); + $this->assertSame('123.456', $unserializedModel->toArray()['price']); + } + + public function testOneWayCasting() + { + // CastsInboundAttributes is used for casting that is unidirectional... only use case I can think of is one-way hashing... + $model = new TestEloquentModelWithCustomCast; + + $model->password = 'secret'; + + $this->assertEquals(hash('sha256', 'secret'), $model->password); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->password); + + $model->password = 'secret2'; + + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + } + + public function testSettingRawAttributesClearsTheCastCache() + { + $model = new TestEloquentModelWithCustomCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + } + + public function testWithCastableInterface() + { + $model = new TestEloquentModelWithCustomCast; + + $model->setRawAttributes([ + 'value_object_with_caster' => serialize(new ValueObject('hello')), + ]); + + $this->assertInstanceOf(ValueObject::class, $model->value_object_with_caster); + $this->assertSame(serialize(new ValueObject('hello')), $model->toArray()['value_object_with_caster']); + + $model->setRawAttributes([ + 'value_object_caster_with_argument' => null, + ]); + + $this->assertSame('argument', $model->value_object_caster_with_argument); + + $model->setRawAttributes([ + 'value_object_caster_with_caster_instance' => serialize(new ValueObject('hello')), + ]); + + $this->assertInstanceOf(ValueObject::class, $model->value_object_caster_with_caster_instance); + } + + public function testGetFromUndefinedCast() + { + $this->expectException(InvalidCastException::class); + + $model = new TestEloquentModelWithCustomCast; + $model->undefined_cast_column; + } + + public function testSetToUndefinedCast() + { + $this->expectException(InvalidCastException::class); + + $model = new TestEloquentModelWithCustomCast; + $this->assertTrue($model->hasCast('undefined_cast_column')); + + $model->undefined_cast_column = 'Glāžšķūņu rūķīši'; + } +} + +class TestEloquentModelWithCustomCast extends Model +{ + /** + * The attributes that aren't mass assignable. + * + * @var string[] + */ + protected $guarded = []; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'address' => AddressCaster::class, + 'price' => DecimalCaster::class, + 'password' => HashCaster::class, + 'other_password' => HashCaster::class.':md5', + 'uppercase' => UppercaseCaster::class, + 'options' => JsonCaster::class, + 'value_object_with_caster' => ValueObject::class, + 'value_object_caster_with_argument' => ValueObject::class.':argument', + 'value_object_caster_with_caster_instance' => ValueObjectWithCasterInstance::class, + 'undefined_cast_column' => UndefinedCast::class, + 'birthday_at' => DateObjectCaster::class, + ]; +} + +class HashCaster implements CastsInboundAttributes +{ + public function __construct($algorithm = 'sha256') + { + $this->algorithm = $algorithm; + } + + public function set($model, $key, $value, $attributes) + { + return [$key => hash($this->algorithm, $value)]; + } +} + +class UppercaseCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return strtoupper($value); + } + + public function set($model, $key, $value, $attributes) + { + return [$key => strtoupper($value)]; + } +} + +class AddressCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + if (is_null($attributes['address_line_one'])) { + return; + } + + return new Address($attributes['address_line_one'], $attributes['address_line_two']); + } + + public function set($model, $key, $value, $attributes) + { + if (is_null($value)) { + return [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + } + + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } +} + +class JsonCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return json_decode($value, true); + } + + public function set($model, $key, $value, $attributes) + { + return json_encode($value); + } +} + +class DecimalCaster implements CastsAttributes, DeviatesCastableAttributes, SerializesCastableAttributes +{ + public function get($model, $key, $value, $attributes) + { + return new Decimal($value); + } + + public function set($model, $key, $value, $attributes) + { + return (string) $value; + } + + public function increment($model, $key, $value, $attributes) + { + return new Decimal($attributes[$key] + $value); + } + + public function decrement($model, $key, $value, $attributes) + { + return new Decimal($attributes[$key] - $value); + } + + public function serialize($model, $key, $value, $attributes) + { + return (string) $value; + } +} + +class ValueObjectCaster implements CastsAttributes +{ + private $argument; + + public function __construct($argument = null) + { + $this->argument = $argument; + } + + public function get($model, $key, $value, $attributes) + { + if ($this->argument) { + return $this->argument; + } + + return unserialize($value); + } + + public function set($model, $key, $value, $attributes) + { + return serialize($value); + } +} + +class ValueObject implements Castable +{ + public $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + public static function castUsing(array $arguments) + { + return new class(...$arguments) implements CastsAttributes, SerializesCastableAttributes + { + private $argument; + + public function __construct($argument = null) + { + $this->argument = $argument; + } + + public function get($model, $key, $value, $attributes) + { + if ($this->argument) { + return $this->argument; + } + + return unserialize($value); + } + + public function set($model, $key, $value, $attributes) + { + return serialize($value); + } + + public function serialize($model, $key, $value, $attributes) + { + return serialize($value); + } + }; + } +} + +class ValueObjectWithCasterInstance extends ValueObject +{ + public static function castUsing(array $arguments) + { + return new ValueObjectCaster; + } +} + +class Address +{ + public $lineOne; + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +} + +final class Decimal +{ + private $value; + private $scale; + + public function __construct($value) + { + $parts = explode('.', (string) $value); + + $this->scale = strlen($parts[1]); + $this->value = (int) str_replace('.', '', $value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString() + { + return substr_replace($this->value, '.', -$this->scale, 0); + } +} + +class DateObjectCaster implements CastsAttributes +{ + private $argument; + + public function __construct($argument = null) + { + $this->argument = $argument; + } + + public function get($model, $key, $value, $attributes) + { + return Carbon::parse($value); + } + + public function set($model, $key, $value, $attributes) + { + return $value->format('Y-m-d'); + } +} diff --git a/tests/Integration/Database/DatabaseLockTest.php b/tests/Integration/Database/DatabaseLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0c0beae75d8fba20e5f7e27361bc0bd23baaa927 --- /dev/null +++ b/tests/Integration/Database/DatabaseLockTest.php @@ -0,0 +1,68 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +class DatabaseLockTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + public function testLockCanHaveASeparateConnection() + { + $this->app['config']->set('cache.stores.database.lock_connection', 'test'); + $this->app['config']->set('database.connections.test', $this->app['config']->get('database.connections.mysql')); + + $this->assertSame('test', Cache::driver('database')->lock('foo')->getConnectionName()); + } + + public function testLockCanBeAcquired() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertFalse($otherLock->get()); + + $lock->release(); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } + + public function testLockCanBeForceReleased() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + + $otherLock = Cache::driver('database')->lock('foo'); + $otherLock->forceRelease(); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } + + public function testExpiredLockCanBeRetrieved() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + DB::table('cache_locks')->update(['expiration' => now()->subDays(1)->getTimestamp()]); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } +} diff --git a/tests/Integration/Database/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/DatabaseMySqlConnectionTest.php deleted file mode 100644 index 0ca3d3f69a0f094e3c79ff8979f14adec04cbe72..0000000000000000000000000000000000000000 --- a/tests/Integration/Database/DatabaseMySqlConnectionTest.php +++ /dev/null @@ -1,85 +0,0 @@ -<?php - -namespace Illuminate\Tests\Integration\Database; - -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; -use Orchestra\Testbench\TestCase; - -/** - * @requires extension pdo_mysql - */ -class DatabaseMySqlConnectionTest extends TestCase -{ - const TABLE = 'player'; - const FLOAT_COL = 'float_col'; - const JSON_COL = 'json_col'; - - const FLOAT_VAL = 0.2; - - protected function getEnvironmentSetUp($app) - { - $app['config']->set('app.debug', 'true'); - $app['config']->set('database.default', 'mysql'); - } - - protected function setUp(): void - { - parent::setUp(); - - if (! isset($_SERVER['CI']) || windows_os()) { - $this->markTestSkipped('This test is only executed on CI in Linux.'); - } - - if (! Schema::hasTable(self::TABLE)) { - Schema::create(self::TABLE, function (Blueprint $table) { - $table->json(self::JSON_COL)->nullable(); - $table->float(self::FLOAT_COL)->nullable(); - }); - } - } - - protected function tearDown(): void - { - DB::table(self::TABLE)->truncate(); - - parent::tearDown(); - } - - /** - * @dataProvider floatComparisonsDataProvider - */ - public function testJsonFloatComparison($value, $operator, $shouldMatch) - { - DB::table(self::TABLE)->insert([self::JSON_COL => '{"rank":'.self::FLOAT_VAL.'}']); - - $this->assertSame( - $shouldMatch, - DB::table(self::TABLE)->where(self::JSON_COL.'->rank', $operator, $value)->exists(), - self::JSON_COL.'->rank should '.($shouldMatch ? '' : 'not ')."be $operator $value" - ); - } - - public function floatComparisonsDataProvider() - { - return [ - [0.2, '=', true], - [0.2, '>', false], - [0.2, '<', false], - [0.1, '=', false], - [0.1, '<', false], - [0.1, '>', true], - [0.3, '=', false], - [0.3, '<', true], - [0.3, '>', false], - ]; - } - - public function testFloatValueStoredCorrectly() - { - DB::table(self::TABLE)->insert([self::FLOAT_COL => self::FLOAT_VAL]); - - $this->assertEquals(self::FLOAT_VAL, DB::table(self::TABLE)->value(self::FLOAT_COL)); - } -} diff --git a/tests/Integration/Database/DatabaseTestCase.php b/tests/Integration/Database/DatabaseTestCase.php index af8c9ff1ea7e3b54d954f2586e5db305394735ea..4b08becfff1a3508f343680408f30fbde7f78612 100644 --- a/tests/Integration/Database/DatabaseTestCase.php +++ b/tests/Integration/Database/DatabaseTestCase.php @@ -2,20 +2,35 @@ namespace Illuminate\Tests\Integration\Database; +use Illuminate\Foundation\Testing\DatabaseMigrations; use Orchestra\Testbench\TestCase; -class DatabaseTestCase extends TestCase +abstract class DatabaseTestCase extends TestCase { - protected function getEnvironmentSetUp($app) + use DatabaseMigrations; + + /** + * The current database driver. + * + * @return string + */ + protected $driver; + + protected function setUp(): void { - $app['config']->set('app.debug', 'true'); + $this->beforeApplicationDestroyed(function () { + foreach (array_keys($this->app['db']->getConnections()) as $name) { + $this->app['db']->purge($name); + } + }); - $app['config']->set('database.default', 'testbench'); + parent::setUp(); + } + + protected function getEnvironmentSetUp($app) + { + $connection = $app['config']->get('database.default'); - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + $this->driver = $app['config']->get("database.connections.$connection.driver"); } } diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 32be59a479c5d9cf4f28f39bdac9a8775db43bb7..105fa7429d7349889e4a9d54122f3df80fb4d0a4 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -13,15 +13,10 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentBelongsToManyTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('uuid'); @@ -43,6 +38,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase }); Schema::create('users_posts', function (Blueprint $table) { + $table->increments('id'); $table->string('user_uuid'); $table->string('post_uuid'); $table->tinyInteger('is_draft')->default(1); @@ -51,12 +47,11 @@ class EloquentBelongsToManyTest extends DatabaseTestCase Schema::create('posts_tags', function (Blueprint $table) { $table->integer('post_id'); - $table->integer('tag_id'); - $table->string('flag')->default(''); + $table->integer('tag_id')->default(0); + $table->string('tag_name')->default('')->nullable(); + $table->string('flag')->default('')->nullable(); $table->timestamps(); }); - - Carbon::setTestNow(null); } public function testBasicCreateAndRetrieve() @@ -90,7 +85,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals( [ 'post_id' => '1', 'tag_id' => '1', 'flag' => 'taylor', - 'created_at' => '2017-10-10 10:10:10', 'updated_at' => '2017-10-10 10:10:10', + 'created_at' => '2017-10-10T10:10:10.000000Z', 'updated_at' => '2017-10-10T10:10:10.000000Z', ], $post->tags[0]->pivot->toArray() ); @@ -135,7 +130,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post->tagsWithCustomPivot()->attach($tag->id); $this->assertInstanceOf(PostTagPivot::class, $post->tagsWithCustomPivot[0]->pivot); - $this->assertSame('1507630210', $post->tagsWithCustomPivot[0]->pivot->getAttributes()['created_at']); + $this->assertEquals('1507630210', $post->tagsWithCustomPivot[0]->pivot->created_at); $this->assertInstanceOf(PostTagPivot::class, $post->tagsWithCustomPivotClass[0]->pivot); $this->assertSame('posts_tags', $post->tagsWithCustomPivotClass()->getTable()); @@ -206,6 +201,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase ); } + /** @group SkipMSSQL */ public function testCustomPivotClassUpdatesTimestamps() { Carbon::setTestNow('2017-10-10 10:10:10'); @@ -216,8 +212,8 @@ class EloquentBelongsToManyTest extends DatabaseTestCase DB::table('posts_tags')->insert([ [ 'post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty', - 'created_at' => '1507630210', - 'updated_at' => '1507630210', + 'created_at' => '2017-10-10 10:10:10', + 'updated_at' => '2017-10-10 10:10:10', ], ]); @@ -229,8 +225,8 @@ class EloquentBelongsToManyTest extends DatabaseTestCase ); foreach ($post->tagsWithCustomExtraPivot as $tag) { $this->assertSame('exclude', $tag->pivot->flag); - $this->assertEquals('1507630210', $tag->pivot->getAttributes()['created_at']); - $this->assertEquals('1507630220', $tag->pivot->getAttributes()['updated_at']); // +10 seconds + $this->assertEquals('2017-10-10 10:10:10', $tag->pivot->getAttributes()['created_at']); + $this->assertEquals('2017-10-10 10:10:20', $tag->pivot->getAttributes()['updated_at']); // +10 seconds } } @@ -334,7 +330,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post = Post::create(['title' => Str::random()]); - $post->tags()->firstOrFail(['id' => 10]); + $post->tags()->firstOrFail(['id']); } public function testFindMethod() @@ -347,12 +343,16 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post->tags()->attach(Tag::all()); $this->assertEquals($tag2->name, $post->tags()->find($tag2->id)->name); + $this->assertCount(0, $post->tags()->findMany([])); $this->assertCount(2, $post->tags()->findMany([$tag->id, $tag2->id])); + $this->assertCount(0, $post->tags()->findMany(new Collection)); + $this->assertCount(2, $post->tags()->findMany(new Collection([$tag->id, $tag2->id]))); } public function testFindOrFailMethod() { $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Integration\Database\EloquentBelongsToManyTest\Tag] 10'); $post = Post::create(['title' => Str::random()]); @@ -363,6 +363,34 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post->tags()->findOrFail(10); } + public function testFindOrFailMethodWithMany() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Integration\Database\EloquentBelongsToManyTest\Tag] 10, 11'); + + $post = Post::create(['title' => Str::random()]); + + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->findOrFail([10, 11]); + } + + public function testFindOrFailMethodWithManyUsingCollection() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Integration\Database\EloquentBelongsToManyTest\Tag] 10, 11'); + + $post = Post::create(['title' => Str::random()]); + + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->findOrFail(new Collection([10, 11])); + } + public function testFindOrNewMethod() { $post = Post::create(['title' => Str::random()]); @@ -373,8 +401,8 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($tag->id, $post->tags()->findOrNew($tag->id)->id); - $this->assertNull($post->tags()->findOrNew('asd')->id); - $this->assertInstanceOf(Tag::class, $post->tags()->findOrNew('asd')); + $this->assertNull($post->tags()->findOrNew(666)->id); + $this->assertInstanceOf(Tag::class, $post->tags()->findOrNew(666)); } public function testFirstOrNewMethod() @@ -387,8 +415,8 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($tag->id, $post->tags()->firstOrNew(['id' => $tag->id])->id); - $this->assertNull($post->tags()->firstOrNew(['id' => 'asd'])->id); - $this->assertInstanceOf(Tag::class, $post->tags()->firstOrNew(['id' => 'asd'])); + $this->assertNull($post->tags()->firstOrNew(['id' => 666])->id); + $this->assertInstanceOf(Tag::class, $post->tags()->firstOrNew(['id' => 666])); } public function testFirstOrCreateMethod() @@ -417,7 +445,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post->tags()->updateOrCreate(['id' => $tag->id], ['name' => 'wavez']); $this->assertSame('wavez', $tag->fresh()->name); - $post->tags()->updateOrCreate(['id' => 'asd'], ['name' => 'dives']); + $post->tags()->updateOrCreate(['id' => 666], ['name' => 'dives']); $this->assertNotNull($post->tags()->whereName('dives')->first()); } @@ -569,6 +597,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertNotSame('2017-10-10 10:10:10', $tag->fresh()->updated_at->toDateTimeString()); } + /** @group SkipMSSQL */ public function testCanRetrieveRelatedIds() { $post = Post::create(['title' => Str::random()]); @@ -587,6 +616,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals([200, 400], $post->tags()->allRelatedIds()->toArray()); } + /** @group SkipMSSQL */ public function testCanTouchRelatedModels() { $post = Post::create(['title' => Str::random()]); @@ -613,6 +643,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertNotSame('2017-10-10 10:10:10', Tag::find(300)->updated_at); } + /** @group SkipMSSQL */ public function testWherePivotOnString() { $tag = Tag::create(['name' => Str::random()]); @@ -629,6 +660,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); } + /** @group SkipMSSQL */ public function testFirstWhere() { $tag = Tag::create(['name' => 'foo']); @@ -645,6 +677,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); } + /** @group SkipMSSQL */ public function testWherePivotOnBoolean() { $tag = Tag::create(['name' => Str::random()]); @@ -661,6 +694,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); } + /** @group SkipMSSQL */ public function testWherePivotInMethod() { $tag = Tag::create(['name' => Str::random()]); @@ -695,6 +729,7 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($relationTags->pluck('id')->toArray(), [$tag1->id, $tag3->id]); } + /** @group SkipMSSQL */ public function testWherePivotNotInMethod() { $tag1 = Tag::create(['name' => Str::random()]); @@ -733,6 +768,42 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $this->assertEquals($relationTags->pluck('id')->toArray(), [$tag1->id, $tag2->id]); } + /** @group SkipMSSQL */ + public function testWherePivotNullMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => null], + ]); + + $relationTag = $post->tagsWithExtraPivot()->wherePivotNull('flag')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag2->getAttributes()); + } + + /** @group SkipMSSQL */ + public function testWherePivotNotNullMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => null], + ]); + + $relationTag = $post->tagsWithExtraPivot()->wherePivotNotNull('flag')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag1->getAttributes()); + } + public function testCanUpdateExistingPivot() { $tag = Tag::create(['name' => Str::random()]); @@ -790,17 +861,17 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $post = Post::create(['title' => Str::random()]); $tag = $post->tagsWithCustomRelatedKey()->create(['name' => Str::random()]); - $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_id); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); $post->tagsWithCustomRelatedKey()->detach($tag); $post->tagsWithCustomRelatedKey()->attach($tag); - $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_id); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); $post->tagsWithCustomRelatedKey()->detach(new Collection([$tag])); $post->tagsWithCustomRelatedKey()->attach(new Collection([$tag])); - $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_id); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); $post->tagsWithCustomRelatedKey()->updateExistingPivot($tag, ['flag' => 'exclude']); $this->assertSame('exclude', $post->tagsWithCustomRelatedKey()->first()->pivot->flag); @@ -839,22 +910,80 @@ class EloquentBelongsToManyTest extends DatabaseTestCase $user->postsWithCustomPivot()->updateExistingPivot($post2->uuid, ['is_draft' => 0]); $this->assertEquals(0, $user->postsWithCustomPivot()->first()->pivot->is_draft); } + + /** @group SkipMSSQL */ + public function testOrderByPivotMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo3'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'foo1'], + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'foo4'], + ['post_id' => $post->id, 'tag_id' => $tag4->id, 'flag' => 'foo2'], + ]); + + $relationTag1 = $post->tagsWithCustomExtraPivot()->orderByPivot('flag', 'asc')->first(); + $this->assertEquals($relationTag1->getAttributes(), $tag2->getAttributes()); + + $relationTag2 = $post->tagsWithCustomExtraPivot()->orderByPivot('flag', 'desc')->first(); + $this->assertEquals($relationTag2->getAttributes(), $tag3->getAttributes()); + } + + public function testFirstOrMethod() + { + $user1 = User::create(['name' => Str::random()]); + $user2 = User::create(['name' => Str::random()]); + $user3 = User::create(['name' => Str::random()]); + $post1 = Post::create(['title' => Str::random()]); + $post2 = Post::create(['title' => Str::random()]); + $post3 = Post::create(['title' => Str::random()]); + + $user1->posts()->sync([$post1->uuid, $post2->uuid]); + $user2->posts()->sync([$post1->uuid, $post2->uuid]); + + $this->assertEquals( + $post1->id, + $user2->posts()->firstOr(function () { + return Post::create(['title' => Str::random()]); + })->id + ); + + $this->assertEquals( + $post3->id, + $user3->posts()->firstOr(function () use ($post3) { + return $post3; + })->id + ); + } } class User extends Model { public $table = 'users'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; protected static function boot() { parent::boot(); + static::creating(function ($model) { $model->setAttribute('uuid', Str::random()); }); } + public function posts() + { + return $this->belongsToMany(Post::class, 'users_posts', 'user_uuid', 'post_uuid', 'uuid', 'uuid') + ->withPivot('is_draft') + ->withTimestamps(); + } + public function postsWithCustomPivot() { return $this->belongsToMany(Post::class, 'users_posts', 'user_uuid', 'post_uuid', 'uuid', 'uuid') @@ -868,17 +997,25 @@ class Post extends Model { public $table = 'posts'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; protected $touches = ['touchingTags']; protected static function boot() { parent::boot(); + static::creating(function ($model) { $model->setAttribute('uuid', Str::random()); }); } + public function users() + { + return $this->belongsToMany(User::class, 'users_posts', 'post_uuid', 'user_uuid', 'uuid', 'uuid') + ->withPivot('is_draft') + ->withTimestamps(); + } + public function tags() { return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') @@ -928,7 +1065,7 @@ class Post extends Model public function tagsWithCustomRelatedKey() { - return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id', 'id', 'name') + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_name', 'id', 'name') ->withPivot('flag'); } @@ -942,7 +1079,7 @@ class Tag extends Model { public $table = 'tags'; public $timestamps = true; - protected $guarded = ['id']; + protected $fillable = ['name']; public function posts() { @@ -954,7 +1091,7 @@ class TouchingTag extends Model { public $table = 'tags'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; protected $touches = ['posts']; public function posts() @@ -967,7 +1104,7 @@ class TagWithCustomPivot extends Model { public $table = 'tags'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; public function posts() { @@ -983,14 +1120,18 @@ class UserPostPivot extends Pivot class PostTagPivot extends Pivot { protected $table = 'posts_tags'; - protected $dateFormat = 'U'; + + public function getCreatedAtAttribute($value) + { + return Carbon::parse($value)->format('U'); + } } class TagWithGlobalScope extends Model { public $table = 'tags'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; public static function boot() { diff --git a/tests/Integration/Database/EloquentBelongsToTest.php b/tests/Integration/Database/EloquentBelongsToTest.php index 5b74d97272b23b762d17806ae3ecbb6e0c7b426d..984939fdad5b4cc61b7ef5fc89357eace847cdf4 100644 --- a/tests/Integration/Database/EloquentBelongsToTest.php +++ b/tests/Integration/Database/EloquentBelongsToTest.php @@ -8,15 +8,10 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentBelongsToTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('slug')->nullable(); @@ -32,14 +27,14 @@ class EloquentBelongsToTest extends DatabaseTestCase { $users = User::has('parent')->get(); - $this->assertEquals(1, $users->count()); + $this->assertCount(1, $users); } public function testHasSelfCustomOwnerKey() { $users = User::has('parentBySlug')->get(); - $this->assertEquals(1, $users->count()); + $this->assertCount(1, $users); } public function testAssociateWithModel() @@ -74,12 +69,70 @@ class EloquentBelongsToTest extends DatabaseTestCase $this->assertEquals($child->id, $child->parent_id); $this->assertFalse($child->relationLoaded('parent')); } + + public function testParentIsNotNull() + { + $child = User::has('parent')->first(); + $parent = null; + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsModel() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + + $this->assertTrue($child->parent()->is($parent)); + $this->assertFalse($child->parent()->isNot($parent)); + } + + public function testParentIsNotAnotherModel() + { + $child = User::has('parent')->first(); + $parent = new User; + $parent->id = 3; + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testNullParentIsNotModel() + { + $child = User::has('parent')->first(); + $child->parent()->dissociate(); + $parent = User::doesntHave('parent')->first(); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherTable() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + $parent->setTable('foo'); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherConnection() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + $parent->setConnection('foo'); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } } class User extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function parent() { diff --git a/tests/Integration/Database/EloquentCollectionFreshTest.php b/tests/Integration/Database/EloquentCollectionFreshTest.php index 745be7ae3074bcfecb8b2b1d30c6c6593d1f0b44..e1c59f7cafe58c17eb558c7ce5d5a1d8281780f0 100644 --- a/tests/Integration/Database/EloquentCollectionFreshTest.php +++ b/tests/Integration/Database/EloquentCollectionFreshTest.php @@ -2,19 +2,15 @@ namespace Illuminate\Tests\Integration\Database; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\Fixtures\User; -/** - * @group integration - */ class EloquentCollectionFreshTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email'); @@ -30,8 +26,11 @@ class EloquentCollectionFreshTest extends DatabaseTestCase $collection = User::all(); - User::whereKey($collection->pluck('id')->toArray())->delete(); + $collection->first()->delete(); + + $freshCollection = $collection->fresh(); - $this->assertEmpty($collection->fresh()->filter()); + $this->assertCount(1, $freshCollection); + $this->assertInstanceOf(EloquentCollection::class, $freshCollection); } } diff --git a/tests/Integration/Database/EloquentCollectionLoadCountTest.php b/tests/Integration/Database/EloquentCollectionLoadCountTest.php index 1f841ff97000b91c2c33714c0ecc918a50218470..b83da3f6c0e1685627e8cd008377ad247b575fe5 100644 --- a/tests/Integration/Database/EloquentCollectionLoadCountTest.php +++ b/tests/Integration/Database/EloquentCollectionLoadCountTest.php @@ -10,15 +10,10 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentCollectionLoadCountTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->unsignedInteger('some_default_value'); @@ -52,9 +47,23 @@ class EloquentCollectionLoadCountTest extends DatabaseTestCase $posts->loadCount('comments'); $this->assertCount(1, DB::getQueryLog()); - $this->assertSame('2', $posts[0]->comments_count); - $this->assertSame('0', $posts[1]->comments_count); - $this->assertSame('2', $posts[0]->getOriginal('comments_count')); + $this->assertEquals('2', $posts[0]->comments_count); + $this->assertEquals('0', $posts[1]->comments_count); + $this->assertEquals('2', $posts[0]->getOriginal('comments_count')); + } + + public function testLoadCountWithSameModels() + { + $posts = Post::all()->push(Post::first()); + + DB::enableQueryLog(); + + $posts->loadCount('comments'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals('2', $posts[0]->comments_count); + $this->assertEquals('0', $posts[1]->comments_count); + $this->assertEquals('2', $posts[2]->comments_count); } public function testLoadCountOnDeletedModels() @@ -66,8 +75,8 @@ class EloquentCollectionLoadCountTest extends DatabaseTestCase $posts->loadCount('comments'); $this->assertCount(1, DB::getQueryLog()); - $this->assertSame('2', $posts[0]->comments_count); - $this->assertSame('0', $posts[1]->comments_count); + $this->assertEquals('2', $posts[0]->comments_count); + $this->assertEquals('0', $posts[1]->comments_count); } public function testLoadCountWithArrayOfRelations() @@ -79,10 +88,10 @@ class EloquentCollectionLoadCountTest extends DatabaseTestCase $posts->loadCount(['comments', 'likes']); $this->assertCount(1, DB::getQueryLog()); - $this->assertSame('2', $posts[0]->comments_count); - $this->assertSame('1', $posts[0]->likes_count); - $this->assertSame('0', $posts[1]->comments_count); - $this->assertSame('0', $posts[1]->likes_count); + $this->assertEquals('2', $posts[0]->comments_count); + $this->assertEquals('1', $posts[0]->likes_count); + $this->assertEquals('0', $posts[1]->comments_count); + $this->assertEquals('0', $posts[1]->likes_count); } public function testLoadCountDoesNotOverrideAttributesWithDefaultValue() @@ -93,7 +102,7 @@ class EloquentCollectionLoadCountTest extends DatabaseTestCase Collection::make([$post])->loadCount('comments'); $this->assertSame(200, $post->some_default_value); - $this->assertSame('2', $post->comments_count); + $this->assertEquals('2', $post->comments_count); } } diff --git a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php index 4c273eb05715cb0fd6ccb05d9d4875b034f448b7..31e101afec1d6068372230c641ca04aad3f3a169 100644 --- a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php +++ b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentCollectionLoadMissingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); }); @@ -37,6 +32,21 @@ class EloquentCollectionLoadMissingTest extends DatabaseTestCase $table->unsignedInteger('comment_id'); }); + Schema::create('post_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('post_sub_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_relation_id'); + }); + + Schema::create('post_sub_sub_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_sub_relation_id'); + }); + User::create(); Post::create(['user_id' => 1]); @@ -46,6 +56,11 @@ class EloquentCollectionLoadMissingTest extends DatabaseTestCase Comment::create(['parent_id' => 2, 'post_id' => 1]); Revision::create(['comment_id' => 1]); + + Post::create(['user_id' => 1]); + PostRelation::create(['post_id' => 2]); + PostSubRelation::create(['post_relation_id' => 1]); + PostSubSubRelation::create(['post_sub_relation_id' => 1]); } public function testLoadMissing() @@ -89,13 +104,27 @@ class EloquentCollectionLoadMissingTest extends DatabaseTestCase $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); $this->assertTrue($posts[0]->comments[1]->parent->relationLoaded('parent')); } + + public function testLoadMissingWithoutInitialLoad() + { + $user = User::first(); + $user->loadMissing('posts.postRelation.postSubRelations.postSubSubRelations'); + + $this->assertEquals(2, $user->posts->count()); + $this->assertNull($user->posts[0]->postRelation); + $this->assertInstanceOf(PostRelation::class, $user->posts[1]->postRelation); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations->count()); + $this->assertInstanceOf(PostSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations->count()); + $this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]); + } } class Comment extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function parent() { @@ -112,7 +141,7 @@ class Post extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function comments() { @@ -123,16 +152,57 @@ class Post extends Model { return $this->belongsTo(User::class); } + + public function postRelation() + { + return $this->hasOne(PostRelation::class); + } +} + +class PostRelation extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + public function postSubRelations() + { + return $this->hasMany(PostSubRelation::class); + } +} + +class PostSubRelation extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + public function postSubSubRelations() + { + return $this->hasMany(PostSubSubRelation::class); + } +} + +class PostSubSubRelation extends Model +{ + public $timestamps = false; + + protected $guarded = []; } class Revision extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; } class User extends Model { public $timestamps = false; + + public function posts() + { + return $this->hasMany(Post::class); + } } diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f5566bd12487c699b3be458db648329af5365089 --- /dev/null +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -0,0 +1,244 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Pagination\Cursor; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +class EloquentCursorPaginateTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title')->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + } + + public function testCursorPaginationOnTopOfColumns() + { + for ($i = 1; $i <= 50; $i++) { + TestPost::create([ + 'title' => 'Title '.$i, + ]); + } + + $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); + } + + public function testPaginationWithUnion() + { + TestPost::create(['title' => 'Hello world', 'user_id' => 1]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + TestPost::create(['title' => '4th', 'user_id' => 4]); + + $table1 = TestPost::query()->whereIn('user_id', [1, 2]); + $table2 = TestPost::query()->whereIn('user_id', [3, 4]); + + $result = $table1->unionAll($table2) + ->orderBy('user_id', 'desc') + ->cursorPaginate(1); + + self::assertSame(['user_id'], $result->getOptions()['parameters']); + } + + public function testPaginationWithDistinct() + { + for ($i = 1; $i <= 3; $i++) { + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); + } + + $query = TestPost::query()->distinct(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertCount(6, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereClause() + { + for ($i = 1; $i <= 3; $i++) { + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + } + + $query = TestPost::query()->whereNull('user_id'); + + $this->assertEquals(3, $query->get()->count()); + $this->assertEquals(3, $query->count()); + $this->assertCount(3, $query->cursorPaginate()->items()); + } + + /** @group SkipMSSQL */ + public function testPaginationWithHasClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->has('posts'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + /** @group SkipMSSQL */ + public function testPaginationWithWhereHasClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + }); + + $this->assertEquals(1, $query->get()->count()); + $this->assertEquals(1, $query->count()); + $this->assertCount(1, $query->cursorPaginate()->items()); + } + + /** @group SkipMSSQL */ + public function testPaginationWithWhereExistsClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + }); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + /** @group SkipMSSQL */ + public function testPaginationWithMultipleWhereClauses() + { + for ($i = 1; $i <= 4; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + TestPost::create(['title' => 'Howdy', 'user_id' => 4]); + } + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + })->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + })->where('id', '<', 5)->orderBy('id'); + + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + $this->assertCount(1, $clonedQuery->cursorPaginate(1)->items()); + $this->assertCount( + 1, + $anotherQuery->cursorPaginate(5, ['*'], 'cursor', new Cursor(['id' => 3])) + ->items() + ); + } + + /** @group SkipMSSQL */ + public function testPaginationWithAliasedOrderBy() + { + for ($i = 1; $i <= 6; $i++) { + TestUser::create(['id' => $i]); + } + + $query = TestUser::query()->select('id as user_id')->orderBy('user_id'); + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertCount(6, $query->cursorPaginate()->items()); + $this->assertCount(3, $clonedQuery->cursorPaginate(3)->items()); + $this->assertCount( + 4, + $anotherQuery->cursorPaginate(10, ['*'], 'cursor', new Cursor(['user_id' => 2])) + ->items() + ); + } + + public function testPaginationWithDistinctColumnsAndSelect() + { + for ($i = 1; $i <= 3; $i++) { + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); + } + + $query = TestPost::query()->orderBy('title')->distinct('title')->select('title'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithDistinctColumnsAndSelectAndJoin() + { + for ($i = 1; $i <= 5; $i++) { + $user = TestUser::create(); + for ($j = 1; $j <= 10; $j++) { + TestPost::create([ + 'title' => 'Title '.$i, + 'user_id' => $user->id, + ]); + } + } + + $query = TestUser::query()->join('test_posts', 'test_posts.user_id', '=', 'test_users.id') + ->distinct('test_users.id')->select('test_users.*'); + + $this->assertEquals(5, $query->get()->count()); + $this->assertEquals(5, $query->count()); + $this->assertCount(5, $query->cursorPaginate()->items()); + } +} + +class TestPost extends Model +{ + protected $guarded = []; +} + +class TestUser extends Model +{ + protected $guarded = []; + + public function posts() + { + return $this->hasMany(TestPost::class, 'user_id'); + } +} diff --git a/tests/Integration/Database/EloquentCustomPivotCastTest.php b/tests/Integration/Database/EloquentCustomPivotCastTest.php index 4fff738c0acba2d96305a513994e9f4452f72a6b..3dfcd81cc651d33af5c21bcfe5e6a361c0469153 100644 --- a/tests/Integration/Database/EloquentCustomPivotCastTest.php +++ b/tests/Integration/Database/EloquentCustomPivotCastTest.php @@ -7,15 +7,10 @@ use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentCustomPivotCastTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email'); diff --git a/tests/Integration/Database/EloquentDeleteTest.php b/tests/Integration/Database/EloquentDeleteTest.php index d859af891e70bc7941dff36504d36dbcc2c0bfe3..d3c2f82d03837aee76c98a9e294363135af2f79f 100644 --- a/tests/Integration/Database/EloquentDeleteTest.php +++ b/tests/Integration/Database/EloquentDeleteTest.php @@ -7,30 +7,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\Fixtures\Post; -use Orchestra\Testbench\TestCase; -/** - * @group integration - */ -class EloquentDeleteTest extends TestCase +class EloquentDeleteTest extends DatabaseTestCase { - protected function getEnvironmentSetUp($app) + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function setUp(): void - { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title')->nullable(); @@ -51,7 +32,8 @@ class EloquentDeleteTest extends TestCase }); } - public function testOnlyDeleteWhatGiven() + /** @group SkipMSSQL */ + public function testDeleteWithLimit() { for ($i = 1; $i <= 10; $i++) { Comment::create([ @@ -62,7 +44,11 @@ class EloquentDeleteTest extends TestCase Post::latest('id')->limit(1)->delete(); $this->assertCount(9, Post::all()); - Post::join('comments', 'comments.post_id', '=', 'posts.id')->where('posts.id', '>', 1)->orderBy('posts.id')->limit(1)->delete(); + Post::join('comments', 'comments.post_id', '=', 'posts.id') + ->where('posts.id', '>', 8) + ->orderBy('posts.id') + ->limit(1) + ->delete(); $this->assertCount(8, Post::all()); } diff --git a/tests/Integration/Database/EloquentFactoryBuilderTest.php b/tests/Integration/Database/EloquentFactoryBuilderTest.php deleted file mode 100644 index d445101a8d946b12a2055a3573c8604f4101137c..0000000000000000000000000000000000000000 --- a/tests/Integration/Database/EloquentFactoryBuilderTest.php +++ /dev/null @@ -1,345 +0,0 @@ -<?php - -namespace Illuminate\Tests\Integration\Database; - -use Faker\Generator; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Factory; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; -use Orchestra\Testbench\TestCase; - -/** - * @group integration - */ -class EloquentFactoryBuilderTest extends TestCase -{ - protected function getEnvironmentSetUp($app) - { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - $app['config']->set('database.connections.alternative-connection', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - - $factory = new Factory($app->make(Generator::class)); - - $factory->define(FactoryBuildableUser::class, function (Generator $faker) { - return [ - 'name' => $faker->name, - 'email' => $faker->unique()->safeEmail, - ]; - }); - - $factory->define(FactoryBuildableProfile::class, function (Generator $faker) { - return [ - 'user_id' => function () { - return factory(FactoryBuildableUser::class)->create()->id; - }, - ]; - }); - - $factory->afterMaking(FactoryBuildableUser::class, function (FactoryBuildableUser $user, Generator $faker) { - $profile = factory(FactoryBuildableProfile::class)->make(['user_id' => $user->id]); - $user->setRelation('profile', $profile); - }); - - $factory->afterMakingState(FactoryBuildableUser::class, 'with_callable_server', function (FactoryBuildableUser $user, Generator $faker) { - $server = factory(FactoryBuildableServer::class) - ->state('callable') - ->make(['user_id' => $user->id]); - - $user->servers->push($server); - }); - - $factory->define(FactoryBuildableTeam::class, function (Generator $faker) { - return [ - 'name' => $faker->name, - 'owner_id' => function () { - return factory(FactoryBuildableUser::class)->create()->id; - }, - ]; - }); - - $factory->afterCreating(FactoryBuildableTeam::class, function (FactoryBuildableTeam $team, Generator $faker) { - $team->users()->attach($team->owner); - }); - - $factory->define(FactoryBuildableServer::class, function (Generator $faker) { - return [ - 'name' => $faker->name, - 'status' => 'active', - 'tags' => ['Storage', 'Data'], - 'user_id' => function () { - return factory(FactoryBuildableUser::class)->create()->id; - }, - ]; - }); - - $factory->state(FactoryBuildableServer::class, 'callable', function (Generator $faker) { - return [ - 'status' => 'callable', - ]; - }); - - $factory->afterCreatingState(FactoryBuildableUser::class, 'with_callable_server', function (FactoryBuildableUser $user, Generator $faker) { - factory(FactoryBuildableServer::class) - ->state('callable') - ->create(['user_id' => $user->id]); - }); - - $factory->state(FactoryBuildableServer::class, 'inline', ['status' => 'inline']); - - $app->singleton(Factory::class, function ($app) use ($factory) { - return $factory; - }); - } - - protected function setUp(): void - { - parent::setUp(); - - Schema::create('users', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('email'); - }); - - Schema::create('profiles', function (Blueprint $table) { - $table->increments('id'); - $table->unsignedInteger('user_id'); - }); - - Schema::create('teams', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('owner_id'); - }); - - Schema::create('team_users', function (Blueprint $table) { - $table->increments('id'); - $table->unsignedInteger('team_id'); - $table->unsignedInteger('user_id'); - }); - - Schema::connection('alternative-connection')->create('users', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('email'); - }); - - Schema::create('servers', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('tags'); - $table->integer('user_id'); - $table->string('status'); - }); - } - - public function testCreatingFactoryModels() - { - $user = factory(FactoryBuildableUser::class)->create(); - - $dbUser = FactoryBuildableUser::find(1); - - $this->assertTrue($user->is($dbUser)); - } - - public function testCreatingFactoryModelsOverridingAttributes() - { - $user = factory(FactoryBuildableUser::class)->create(['name' => 'Zain']); - - $this->assertSame('Zain', $user->name); - } - - public function testCreatingCollectionOfModels() - { - $users = factory(FactoryBuildableUser::class, 3)->create(); - - $instances = factory(FactoryBuildableUser::class, 3)->make(); - - $this->assertInstanceOf(Collection::class, $users); - $this->assertInstanceOf(Collection::class, $instances); - $this->assertCount(3, $users); - $this->assertCount(3, $instances); - $this->assertCount(3, FactoryBuildableUser::find($users->pluck('id')->toArray())); - $this->assertCount(0, FactoryBuildableUser::find($instances->pluck('id')->toArray())); - } - - public function testCreateManyCollectionOfModels() - { - $users = factory(FactoryBuildableUser::class)->createMany([ - [ - 'name' => 'Taylor', - ], - [ - 'name' => 'John', - ], - [ - 'name' => 'Doe', - ], - ]); - $this->assertInstanceOf(Collection::class, $users); - $this->assertCount(3, $users); - $this->assertCount(3, FactoryBuildableUser::find($users->pluck('id')->toArray())); - $this->assertEquals(['Taylor', 'John', 'Doe'], $users->pluck('name')->toArray()); - } - - public function testCreatingModelsWithCallableState() - { - $server = factory(FactoryBuildableServer::class)->create(); - - $callableServer = factory(FactoryBuildableServer::class)->state('callable')->create(); - - $this->assertSame('active', $server->status); - $this->assertEquals(['Storage', 'Data'], $server->tags); - $this->assertSame('callable', $callableServer->status); - } - - public function testCreatingModelsWithInlineState() - { - $server = factory(FactoryBuildableServer::class)->create(); - - $inlineServer = factory(FactoryBuildableServer::class)->state('inline')->create(); - - $this->assertSame('active', $server->status); - $this->assertSame('inline', $inlineServer->status); - } - - public function testCreatingModelsWithRelationships() - { - factory(FactoryBuildableUser::class, 2) - ->create() - ->each(function ($user) { - $user->servers()->saveMany(factory(FactoryBuildableServer::class, 2)->make()); - }) - ->each(function ($user) { - $this->assertCount(2, $user->servers); - }); - } - - public function testCreatingModelsOnCustomConnection() - { - $user = factory(FactoryBuildableUser::class) - ->connection('alternative-connection') - ->create(); - - $dbUser = FactoryBuildableUser::on('alternative-connection')->find(1); - - $this->assertSame('alternative-connection', $user->getConnectionName()); - $this->assertTrue($user->is($dbUser)); - } - - public function testCreatingModelsWithAfterCallback() - { - $team = factory(FactoryBuildableTeam::class)->create(); - - $this->assertTrue($team->users->contains($team->owner)); - } - - public function testCreatingModelsWithAfterCallbackState() - { - $user = factory(FactoryBuildableUser::class)->state('with_callable_server')->create(); - - $this->assertNotNull($user->profile); - $this->assertNotNull($user->servers->where('status', 'callable')->first()); - } - - public function testMakingModelsWithACustomConnection() - { - $user = factory(FactoryBuildableUser::class) - ->connection('alternative-connection') - ->make(); - - $this->assertSame('alternative-connection', $user->getConnectionName()); - } - - public function testMakingModelsWithAfterCallback() - { - $user = factory(FactoryBuildableUser::class)->make(); - - $this->assertNotNull($user->profile); - } - - public function testMakingModelsWithAfterCallbackState() - { - $user = factory(FactoryBuildableUser::class)->state('with_callable_server')->make(); - - $this->assertNotNull($user->profile); - $this->assertNotNull($user->servers->where('status', 'callable')->first()); - } -} - -class FactoryBuildableUser extends Model -{ - public $table = 'users'; - public $timestamps = false; - protected $guarded = ['id']; - - public function servers() - { - return $this->hasMany(FactoryBuildableServer::class, 'user_id'); - } - - public function profile() - { - return $this->hasOne(FactoryBuildableProfile::class, 'user_id'); - } -} - -class FactoryBuildableProfile extends Model -{ - public $table = 'profiles'; - public $timestamps = false; - protected $guarded = ['id']; - - public function user() - { - return $this->belongsTo(FactoryBuildableUser::class, 'user_id'); - } -} - -class FactoryBuildableTeam extends Model -{ - public $table = 'teams'; - public $timestamps = false; - protected $guarded = ['id']; - - public function owner() - { - return $this->belongsTo(FactoryBuildableUser::class, 'owner_id'); - } - - public function users() - { - return $this->belongsToMany( - FactoryBuildableUser::class, - 'team_users', - 'team_id', - 'user_id' - ); - } -} - -class FactoryBuildableServer extends Model -{ - public $table = 'servers'; - public $timestamps = false; - protected $guarded = ['id']; - public $casts = ['tags' => 'array']; - - public function user() - { - return $this->belongsTo(FactoryBuildableUser::class, 'user_id'); - } -} diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index 08fc7254f95abcc108e26de8943270d94d726b8a..2a87611b484780f72aa8c6f41f9638754d26724f 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -9,15 +9,10 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentHasManyThroughTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('slug')->nullable(); @@ -47,7 +42,7 @@ class EloquentHasManyThroughTest extends DatabaseTestCase { $user = User::create(['name' => Str::random()]); - $team1 = Team::create(['id' => 10, 'owner_id' => $user->id]); + $team1 = Team::create(['owner_id' => $user->id]); $team2 = Team::create(['owner_id' => $user->id]); $mate1 = User::create(['name' => 'John', 'team_id' => $team1->id]); @@ -94,7 +89,7 @@ class EloquentHasManyThroughTest extends DatabaseTestCase $users = User::has('teamMates')->get(); - $this->assertEquals(1, $users->count()); + $this->assertCount(1, $users); } public function testHasSelfCustomOwnerKey() @@ -107,7 +102,7 @@ class EloquentHasManyThroughTest extends DatabaseTestCase $users = User::has('teamMatesBySlug')->get(); - $this->assertEquals(1, $users->count()); + $this->assertCount(1, $users); } public function testHasSameParentAndThroughParentTable() @@ -130,7 +125,7 @@ class User extends Model { public $table = 'users'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function teamMates() { @@ -152,7 +147,7 @@ class UserWithGlobalScope extends Model { public $table = 'users'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public static function boot() { diff --git a/tests/Integration/Database/EloquentHasOneIsTest.php b/tests/Integration/Database/EloquentHasOneIsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b61dbca0f796bf0fe808d516922a1778af139929 --- /dev/null +++ b/tests/Integration/Database/EloquentHasOneIsTest.php @@ -0,0 +1,98 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentHasOneIsTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentHasOneIsTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + }); + + Schema::create('attachments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id')->nullable(); + }); + + $post = Post::create(); + $post->attachment()->create(); + } + + public function testChildIsNotNull() + { + $parent = Post::first(); + $child = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsModel() + { + $parent = Post::first(); + $child = Attachment::first(); + + $this->assertTrue($parent->attachment()->is($child)); + $this->assertFalse($parent->attachment()->isNot($child)); + } + + public function testChildIsNotAnotherModel() + { + $parent = Post::first(); + $child = new Attachment; + $child->id = 2; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testNullChildIsNotModel() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->post_id = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherTable() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setTable('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherConnection() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setConnection('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } +} + +class Attachment extends Model +{ + public $timestamps = false; +} + +class Post extends Model +{ + public function attachment() + { + return $this->hasOne(Attachment::class); + } +} diff --git a/tests/Integration/Database/EloquentHasOneOfManyTest.php b/tests/Integration/Database/EloquentHasOneOfManyTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fc1edfbf8b75758ff56a84199adc861146d8061e --- /dev/null +++ b/tests/Integration/Database/EloquentHasOneOfManyTest.php @@ -0,0 +1,62 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentHasOneOfManyTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentHasOneOfManyTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('users', function ($table) { + $table->id(); + }); + + Schema::create('logins', function ($table) { + $table->id(); + $table->foreignId('user_id'); + }); + } + + public function testItOnlyEagerLoadsRequiredModels() + { + $this->retrievedLogins = 0; + User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $models) { + foreach ($models as $model) { + if (get_class($model) == Login::class) { + $this->retrievedLogins++; + } + } + }); + + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + + User::with('latest_login')->get(); + + $this->assertSame(2, $this->retrievedLogins); + } +} + +class User extends Model +{ + protected $guarded = []; + public $timestamps = false; + + public function latest_login() + { + return $this->hasOne(Login::class)->ofMany(); + } +} + +class Login extends Model +{ + protected $guarded = []; + public $timestamps = false; +} diff --git a/tests/Integration/Database/EloquentLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentLazyEagerLoadingTest.php index 7084fc094d3d4937e5af1c8a29a121a9b277c13e..dc6422daa3a44581b7ed809eacdbb3fb3febcf73 100644 --- a/tests/Integration/Database/EloquentLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentLazyEagerLoadingTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentLazyEagerLoadingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('one', function (Blueprint $table) { $table->increments('id'); }); @@ -57,7 +52,7 @@ class Model1 extends Model { public $table = 'one'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; protected $with = ['twos']; public function twos() @@ -75,7 +70,7 @@ class Model2 extends Model { public $table = 'two'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function one() { @@ -87,7 +82,7 @@ class Model3 extends Model { public $table = 'three'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function one() { diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..75f0b0f2fc111568cf9111c1671a9610113bf6de --- /dev/null +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -0,0 +1,130 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Database\Eloquent\MassPrunable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Events\ModelsPruned; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use LogicException; +use Mockery as m; + +/** @group SkipMSSQL */ +class EloquentMassPrunableTest extends DatabaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Container::setInstance($container = new Container); + + $container->singleton(Dispatcher::class, function () { + return m::mock(Dispatcher::class); + }); + + $container->alias(Dispatcher::class, 'events'); + } + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + collect([ + 'mass_prunable_test_models', + 'mass_prunable_soft_delete_test_models', + 'mass_prunable_test_model_missing_prunable_methods', + ])->each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Please implement', + ); + + MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(2) + ->with(m::type(ModelsPruned::class)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + MassPrunableTestModel::insert($chunk->all()); + }); + + $count = (new MassPrunableTestModel)->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, MassPrunableTestModel::count()); + } + + public function testPrunesSoftDeletedRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(3) + ->with(m::type(ModelsPruned::class)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + MassPrunableSoftDeleteTestModel::insert($chunk->all()); + }); + + $count = (new MassPrunableSoftDeleteTestModel)->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, MassPrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, MassPrunableSoftDeleteTestModel::withTrashed()->count()); + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + + m::close(); + } +} + +class MassPrunableTestModel extends Model +{ + use MassPrunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class MassPrunableSoftDeleteTestModel extends Model +{ + use MassPrunable, SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class MassPrunableTestModelMissingPrunableMethod extends Model +{ + use MassPrunable; +} diff --git a/tests/Integration/Database/EloquentModelCustomEventsTest.php b/tests/Integration/Database/EloquentModelCustomEventsTest.php index 01bb22415e07eb2406de9faf87b1fe1bb620e52c..f214ec40142bd802a5ce6ae67a95da6860ad19b4 100644 --- a/tests/Integration/Database/EloquentModelCustomEventsTest.php +++ b/tests/Integration/Database/EloquentModelCustomEventsTest.php @@ -8,24 +8,24 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelCustomEventsTest extends DatabaseTestCase { protected function setUp(): void { parent::setUp(); - Schema::create('test_model1', function (Blueprint $table) { - $table->increments('id'); - }); - Event::listen(CustomEvent::class, function () { $_SERVER['fired_event'] = true; }); } + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_model1', function (Blueprint $table) { + $table->increments('id'); + }); + } + public function testFlushListenersClearsCustomEvents() { $_SERVER['fired_event'] = false; @@ -52,7 +52,7 @@ class TestModel1 extends Model public $dispatchesEvents = ['created' => CustomEvent::class]; public $table = 'test_model1'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; } class CustomEvent diff --git a/tests/Integration/Database/EloquentModelDateCastingTest.php b/tests/Integration/Database/EloquentModelDateCastingTest.php index ff75e1b1be62e4a287e3c9358e6369f18805079d..71ce224bedc59aca6a5f9b490097bc38c5ac452f 100644 --- a/tests/Integration/Database/EloquentModelDateCastingTest.php +++ b/tests/Integration/Database/EloquentModelDateCastingTest.php @@ -3,24 +3,22 @@ namespace Illuminate\Tests\Integration\Database\EloquentModelDateCastingTest; use Carbon\Carbon; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelDateCastingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('test_model1', function (Blueprint $table) { $table->increments('id'); $table->date('date_field')->nullable(); $table->datetime('datetime_field')->nullable(); + $table->date('immutable_date_field')->nullable(); + $table->datetime('immutable_datetime_field')->nullable(); }); } @@ -36,17 +34,97 @@ class EloquentModelDateCastingTest extends DatabaseTestCase $this->assertInstanceOf(Carbon::class, $user->date_field); $this->assertInstanceOf(Carbon::class, $user->datetime_field); } + + public function testDatesFormattedAttributeBindings() + { + $bindings = []; + + $this->app->make('db')->listen(static function ($query) use (&$bindings) { + $bindings = $query->bindings; + }); + + TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15', + ]); + + $this->assertSame(['2019-10-01', '2019-10-01 10:15:20', '2019-10-01', '2019-10-01 10:15'], $bindings); + } + + public function testDatesFormattedArrayAndJson() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15', + ]); + + $expected = [ + 'date_field' => '2019-10', + 'datetime_field' => '2019-10 10:15', + 'immutable_date_field' => '2019-10', + 'immutable_datetime_field' => '2019-10 10:15', + 'id' => 1, + ]; + + $this->assertSame($expected, $user->toArray()); + $this->assertSame(json_encode($expected), $user->toJson()); + } + + public function testCustomDateCastsAreComparedAsDatesForCarbonInstances() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15:20', + ]); + + $user->date_field = new Carbon('2019-10-01'); + $user->datetime_field = new Carbon('2019-10-01 10:15:20'); + $user->immutable_date_field = new CarbonImmutable('2019-10-01'); + $user->immutable_datetime_field = new CarbonImmutable('2019-10-01 10:15:20'); + + $this->assertArrayNotHasKey('date_field', $user->getDirty()); + $this->assertArrayNotHasKey('datetime_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_date_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_datetime_field', $user->getDirty()); + } + + public function testCustomDateCastsAreComparedAsDatesForStringValues() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15:20', + ]); + + $user->date_field = '2019-10-01'; + $user->datetime_field = '2019-10-01 10:15:20'; + $user->immutable_date_field = '2019-10-01'; + $user->immutable_datetime_field = '2019-10-01 10:15:20'; + + $this->assertArrayNotHasKey('date_field', $user->getDirty()); + $this->assertArrayNotHasKey('datetime_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_date_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_datetime_field', $user->getDirty()); + } } class TestModel1 extends Model { public $table = 'test_model1'; public $timestamps = false; - protected $guarded = ['id']; - protected $dates = ['date_field', 'datetime_field']; + protected $guarded = []; public $casts = [ 'date_field' => 'date:Y-m', 'datetime_field' => 'datetime:Y-m H:i', + 'immutable_date_field' => 'date:Y-m', + 'immutable_datetime_field' => 'datetime:Y-m H:i', ]; } diff --git a/tests/Integration/Database/EloquentModelDecimalCastingTest.php b/tests/Integration/Database/EloquentModelDecimalCastingTest.php index 97ea7d10421d565f631e52c446bc2b25fd1dfb6d..fb7e35d490015edb7b7f27e4e6b5bf6392fd51dd 100644 --- a/tests/Integration/Database/EloquentModelDecimalCastingTest.php +++ b/tests/Integration/Database/EloquentModelDecimalCastingTest.php @@ -7,15 +7,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelDecimalCastingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('test_model1', function (Blueprint $table) { $table->increments('id'); $table->decimal('decimal_field_2', 8, 2)->nullable(); @@ -50,7 +45,7 @@ class TestModel1 extends Model { public $table = 'test_model1'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public $casts = [ 'decimal_field_2' => 'decimal:2', diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cebcbdd075ea2a554eeeaa302cbca0a16a2fc69c --- /dev/null +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -0,0 +1,341 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Contracts\Encryption\Encrypter; +use Illuminate\Database\Eloquent\Casts\ArrayObject; +use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject; +use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Schema; +use stdClass; + +class EloquentModelEncryptedCastingTest extends DatabaseTestCase +{ + protected $encrypter; + + protected function setUp(): void + { + parent::setUp(); + + $this->encrypter = $this->mock(Encrypter::class); + Crypt::swap($this->encrypter); + + Model::$encrypter = null; + } + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('encrypted_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('secret', 1000)->nullable(); + $table->text('secret_array')->nullable(); + $table->text('secret_json')->nullable(); + $table->text('secret_object')->nullable(); + $table->text('secret_collection')->nullable(); + }); + } + + public function testStringsAreCastable() + { + $this->encrypter->expects('encrypt') + ->with('this is a secret string', false) + ->andReturn('encrypted-secret-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-string', false) + ->andReturn('this is a secret string'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret' => 'this is a secret string', + ]); + + $this->assertSame('this is a secret string', $subject->secret); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret' => 'encrypted-secret-string', + ]); + } + + public function testArraysAreCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-array-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-array-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_array' => ['key1' => 'value1'], + ]); + + $this->assertSame(['key1' => 'value1'], $subject->secret_array); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => 'encrypted-secret-array-string', + ]); + } + + public function testJsonIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-json-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_json' => ['key1' => 'value1'], + ]); + + $this->assertSame(['key1' => 'value1'], $subject->secret_json); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_json' => 'encrypted-secret-json-string', + ]); + } + + public function testJsonAttributeIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-json-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string', false) + ->andReturn('{"key1":"value1"}'); + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1","key2":"value2"}', false) + ->andReturn('encrypted-secret-json-string2'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string2', false) + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast([ + 'secret_json' => ['key1' => 'value1'], + ]); + $subject->fill([ + 'secret_json->key2' => 'value2', + ]); + $subject->save(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $subject->secret_json); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_json' => 'encrypted-secret-json-string2', + ]); + } + + public function testObjectIsCastable() + { + $object = new stdClass; + $object->key1 = 'value1'; + + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-object-string'); + $this->encrypter->expects('decrypt') + ->twice() + ->with('encrypted-secret-object-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $object */ + $object = EncryptedCast::create([ + 'secret_object' => $object, + ]); + + $this->assertInstanceOf(stdClass::class, $object->secret_object); + $this->assertSame('value1', $object->secret_object->key1); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $object->id, + 'secret_object' => 'encrypted-secret-object-string', + ]); + } + + public function testCollectionIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-collection-string'); + $this->encrypter->expects('decrypt') + ->twice() + ->with('encrypted-secret-collection-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_collection' => new Collection(['key1' => 'value1']), + ]); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string', + ]); + } + + public function testAsEncryptedCollection() + { + $this->encrypter->expects('encryptString') + ->twice() + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-collection-string-1'); + $this->encrypter->expects('encryptString') + ->times(12) + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-collection-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-collection-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast; + + $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::class]); + + $subject->secret_collection = new Collection(['key1' => 'value1']); + $subject->secret_collection->put('key2', 'value2'); + + $subject->save(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertSame('value2', $subject->secret_collection->get('key2')); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertSame('value2', $subject->secret_collection->get('key2')); + + $subject->secret_collection = null; + $subject->save(); + + $this->assertNull($subject->secret_collection); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => null, + ]); + + $this->assertNull($subject->fresh()->secret_collection); + } + + public function testAsEncryptedArrayObject() + { + $this->encrypter->expects('encryptString') + ->once() + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-array-string-1'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-array-string-1') + ->andReturn('{"key1":"value1"}'); + $this->encrypter->expects('encryptString') + ->times(12) + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-array-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-array-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast; + + $subject->mergeCasts(['secret_array' => AsEncryptedArrayObject::class]); + + $subject->secret_array = ['key1' => 'value1']; + $subject->secret_array['key2'] = 'value2'; + + $subject->save(); + + $this->assertInstanceOf(ArrayObject::class, $subject->secret_array); + $this->assertSame('value1', $subject->secret_array['key1']); + $this->assertSame('value2', $subject->secret_array['key2']); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => 'encrypted-secret-array-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(ArrayObject::class, $subject->secret_array); + $this->assertSame('value1', $subject->secret_array['key1']); + $this->assertSame('value2', $subject->secret_array['key2']); + + $subject->secret_array = null; + $subject->save(); + + $this->assertNull($subject->secret_array); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => null, + ]); + + $this->assertNull($subject->fresh()->secret_array); + } + + public function testCustomEncrypterCanBeSpecified() + { + $customEncrypter = $this->mock(Encrypter::class); + + $this->assertNull(Model::$encrypter); + + Model::encryptUsing($customEncrypter); + + $this->assertSame($customEncrypter, Model::$encrypter); + + $this->encrypter->expects('encrypt') + ->never(); + $this->encrypter->expects('decrypt') + ->never(); + $customEncrypter->expects('encrypt') + ->with('this is a secret string', false) + ->andReturn('encrypted-secret-string'); + $customEncrypter->expects('decrypt') + ->with('encrypted-secret-string', false) + ->andReturn('this is a secret string'); + + /** @var \Illuminate\Tests\Integration\Database\EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret' => 'this is a secret string', + ]); + + $this->assertSame('this is a secret string', $subject->secret); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret' => 'encrypted-secret-string', + ]); + } +} + +/** + * @property $secret + * @property $secret_array + * @property $secret_json + * @property $secret_object + * @property $secret_collection + */ +class EncryptedCast extends Model +{ + public $timestamps = false; + protected $guarded = []; + + public $casts = [ + 'secret' => 'encrypted', + 'secret_array' => 'encrypted:array', + 'secret_json' => 'encrypted:json', + 'secret_object' => 'encrypted:object', + 'secret_collection' => 'encrypted:collection', + ]; +} diff --git a/tests/Integration/Database/EloquentModelEnumCastingTest.php b/tests/Integration/Database/EloquentModelEnumCastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8007d6f8334416421347cfeaa9421172b2629c04 --- /dev/null +++ b/tests/Integration/Database/EloquentModelEnumCastingTest.php @@ -0,0 +1,201 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +if (PHP_VERSION_ID >= 80100) { + include 'Enums.php'; +} + +/** + * @requires PHP 8.1 + */ +class EloquentModelEnumCastingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('enum_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('string_status', 100)->nullable(); + $table->integer('integer_status')->nullable(); + $table->string('arrayable_status')->nullable(); + }); + } + + public function testEnumsAreCastable() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ]); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals(IntegerStatus::pending, $model->integer_status); + $this->assertEquals(ArrayableStatus::pending, $model->arrayable_status); + } + + public function testEnumsReturnNullWhenNull() + { + DB::table('enum_casts')->insert([ + 'string_status' => null, + 'integer_status' => null, + 'arrayable_status' => null, + ]); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(null, $model->string_status); + $this->assertEquals(null, $model->integer_status); + $this->assertEquals(null, $model->arrayable_status); + } + + public function testEnumsAreCastableToArray() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => StringStatus::pending, + 'integer_status' => IntegerStatus::pending, + 'arrayable_status' => ArrayableStatus::pending, + ]); + + $this->assertEquals([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => [ + 'name' => 'pending', + 'value' => 'pending', + 'description' => 'pending status description', + ], + ], $model->toArray()); + } + + public function testEnumsAreCastableToArrayWhenNull() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => null, + 'integer_status' => null, + 'arrayable_status' => null, + ]); + + $this->assertEquals([ + 'string_status' => null, + 'integer_status' => null, + 'arrayable_status' => null, + ], $model->toArray()); + } + + public function testEnumsAreConvertedOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => StringStatus::pending, + 'integer_status' => IntegerStatus::pending, + 'arrayable_status' => ArrayableStatus::pending, + ]); + + $model->save(); + + $this->assertEquals((object) [ + 'id' => $model->id, + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ], DB::table('enum_casts')->where('id', $model->id)->first()); + } + + public function testEnumsAcceptNullOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => null, + 'integer_status' => null, + 'arrayable_status' => null, + ]); + + $model->save(); + + $this->assertEquals((object) [ + 'id' => $model->id, + 'string_status' => null, + 'integer_status' => null, + 'arrayable_status' => null, + ], DB::table('enum_casts')->where('id', $model->id)->first()); + } + + public function testEnumsAcceptBackedValueOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ]); + + $model->save(); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals(IntegerStatus::pending, $model->integer_status); + $this->assertEquals(ArrayableStatus::pending, $model->arrayable_status); + } + + public function testFirstOrNew() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ]); + + $model = EloquentModelEnumCastingTestModel::firstOrNew([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingTestModel::firstOrNew([ + 'string_status' => StringStatus::done, + ]); + + $this->assertTrue($model->exists); + $this->assertFalse($model2->exists); + + $model2->save(); + + $this->assertEquals(StringStatus::done, $model2->string_status); + } + + public function testFirstOrCreate() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + ]); + + $model = EloquentModelEnumCastingTestModel::firstOrCreate([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingTestModel::firstOrCreate([ + 'string_status' => StringStatus::done, + ]); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals(StringStatus::done, $model2->string_status); + } +} + +class EloquentModelEnumCastingTestModel extends Model +{ + public $timestamps = false; + protected $guarded = []; + protected $table = 'enum_casts'; + + public $casts = [ + 'string_status' => StringStatus::class, + 'integer_status' => IntegerStatus::class, + 'arrayable_status' => ArrayableStatus::class, + ]; +} diff --git a/tests/Integration/Database/EloquentModelImmutableDateCastingTest.php b/tests/Integration/Database/EloquentModelImmutableDateCastingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5d6a46865a6482bb96bfacccd43dd99e23428170 --- /dev/null +++ b/tests/Integration/Database/EloquentModelImmutableDateCastingTest.php @@ -0,0 +1,71 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentModelDateCastingTest; + +use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentModelImmutableDateCastingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_model_immutable', function (Blueprint $table) { + $table->increments('id'); + $table->date('date_field')->nullable(); + $table->datetime('datetime_field')->nullable(); + }); + } + + public function testDatesAreImmutableCastable() + { + $model = TestModelImmutable::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame('2019-10-01T00:00:00.000000Z', $model->toArray()['date_field']); + $this->assertSame('2019-10-01T10:15:20.000000Z', $model->toArray()['datetime_field']); + $this->assertInstanceOf(CarbonImmutable::class, $model->date_field); + $this->assertInstanceOf(CarbonImmutable::class, $model->datetime_field); + } + + public function testDatesAreImmutableAndCustomCastable() + { + $model = TestModelCustomImmutable::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame('2019-10', $model->toArray()['date_field']); + $this->assertSame('2019-10 10:15', $model->toArray()['datetime_field']); + $this->assertInstanceOf(CarbonImmutable::class, $model->date_field); + $this->assertInstanceOf(CarbonImmutable::class, $model->datetime_field); + } +} + +class TestModelImmutable extends Model +{ + public $table = 'test_model_immutable'; + public $timestamps = false; + protected $guarded = []; + + public $casts = [ + 'date_field' => 'immutable_date', + 'datetime_field' => 'immutable_datetime', + ]; +} + +class TestModelCustomImmutable extends Model +{ + public $table = 'test_model_immutable'; + public $timestamps = false; + protected $guarded = []; + + public $casts = [ + 'date_field' => 'immutable_date:Y-m', + 'datetime_field' => 'immutable_datetime:Y-m H:i', + ]; +} diff --git a/tests/Integration/Database/EloquentModelJsonCastingTest.php b/tests/Integration/Database/EloquentModelJsonCastingTest.php index 61e3338271b226bb09a4dad506113f49d539f8da..b81660aa818eb6701725a948bc1317d59b46a37d 100644 --- a/tests/Integration/Database/EloquentModelJsonCastingTest.php +++ b/tests/Integration/Database/EloquentModelJsonCastingTest.php @@ -9,15 +9,10 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; use stdClass; -/** - * @group integration - */ class EloquentModelJsonCastingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('json_casts', function (Blueprint $table) { $table->increments('id'); $table->json('basic_string_as_json_field')->nullable(); @@ -30,7 +25,7 @@ class EloquentModelJsonCastingTest extends DatabaseTestCase public function testStringsAreCastable() { - /** @var JsonCast $object */ + /** @var \Illuminate\Tests\Integration\Database\EloquentModelJsonCastingTest\JsonCast $object */ $object = JsonCast::create([ 'basic_string_as_json_field' => 'this is a string', 'json_string_as_json_field' => '{"key1":"value1"}', @@ -42,7 +37,7 @@ class EloquentModelJsonCastingTest extends DatabaseTestCase public function testArraysAreCastable() { - /** @var JsonCast $object */ + /** @var \Illuminate\Tests\Integration\Database\EloquentModelJsonCastingTest\JsonCast $object */ $object = JsonCast::create([ 'array_as_json_field' => ['key1' => 'value1'], ]); @@ -52,10 +47,10 @@ class EloquentModelJsonCastingTest extends DatabaseTestCase public function testObjectsAreCastable() { - $object = new stdClass(); + $object = new stdClass; $object->key1 = 'value1'; - /** @var JsonCast $user */ + /** @var \Illuminate\Tests\Integration\Database\EloquentModelJsonCastingTest\JsonCast $user */ $user = JsonCast::create([ 'object_as_json_field' => $object, ]); @@ -66,7 +61,7 @@ class EloquentModelJsonCastingTest extends DatabaseTestCase public function testCollectionsAreCastable() { - /** @var JsonCast $user */ + /** @var \Illuminate\Tests\Integration\Database\EloquentModelJsonCastingTest\JsonCast $user */ $user = JsonCast::create([ 'collection_as_json_field' => new Collection(['key1' => 'value1']), ]); @@ -87,7 +82,7 @@ class JsonCast extends Model { public $table = 'json_casts'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public $casts = [ 'basic_string_as_json_field' => 'json', diff --git a/tests/Integration/Database/EloquentModelLoadCountTest.php b/tests/Integration/Database/EloquentModelLoadCountTest.php index a0db524411c97d16a6321de299c23948eaddf086..121a729f6d03cf19074b8e69665f905ddcd1d856 100644 --- a/tests/Integration/Database/EloquentModelLoadCountTest.php +++ b/tests/Integration/Database/EloquentModelLoadCountTest.php @@ -2,21 +2,17 @@ namespace Illuminate\Tests\Integration\Database\EloquentModelLoadCountTest; +use DB; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelLoadCountTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('base_models', function (Blueprint $table) { $table->increments('id'); }); @@ -49,11 +45,11 @@ class EloquentModelLoadCountTest extends DatabaseTestCase { $model = BaseModel::first(); - \DB::enableQueryLog(); + DB::enableQueryLog(); $model->loadCount('related1'); - $this->assertCount(1, \DB::getQueryLog()); + $this->assertCount(1, DB::getQueryLog()); $this->assertEquals(2, $model->related1_count); } @@ -61,11 +57,11 @@ class EloquentModelLoadCountTest extends DatabaseTestCase { $model = BaseModel::first(); - \DB::enableQueryLog(); + DB::enableQueryLog(); $model->loadCount(['related1', 'related2']); - $this->assertCount(1, \DB::getQueryLog()); + $this->assertCount(1, DB::getQueryLog()); $this->assertEquals(2, $model->related1_count); $this->assertEquals(1, $model->related2_count); } @@ -74,7 +70,7 @@ class EloquentModelLoadCountTest extends DatabaseTestCase { $model = BaseModel::first(); - $this->assertEquals(null, $model->deletedrelated_count); + $this->assertNull($model->deletedrelated_count); $model->loadCount('deletedrelated'); @@ -84,7 +80,7 @@ class EloquentModelLoadCountTest extends DatabaseTestCase $model = BaseModel::first(); - $this->assertEquals(null, $model->deletedrelated_count); + $this->assertNull($model->deletedrelated_count); $model->loadCount('deletedrelated'); @@ -96,7 +92,7 @@ class BaseModel extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function related1() { diff --git a/tests/Integration/Database/EloquentModelLoadMissingTest.php b/tests/Integration/Database/EloquentModelLoadMissingTest.php index 5b15f77dab87a55b35c9a25bbd445867095aa0db..d68f3b4a03a386c48713c6bba4afcd28b03940c7 100644 --- a/tests/Integration/Database/EloquentModelLoadMissingTest.php +++ b/tests/Integration/Database/EloquentModelLoadMissingTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelLoadMissingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); }); @@ -50,7 +45,7 @@ class Comment extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function parent() { diff --git a/tests/Integration/Database/EloquentModelRefreshTest.php b/tests/Integration/Database/EloquentModelRefreshTest.php index 8fb2bcb95180b481963e4f3bac8dc7ac06375e3a..e51b12bdab3a161d483e13c35e9b8930feba1483 100644 --- a/tests/Integration/Database/EloquentModelRefreshTest.php +++ b/tests/Integration/Database/EloquentModelRefreshTest.php @@ -9,15 +9,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentModelRefreshTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); @@ -62,6 +57,7 @@ class EloquentModelRefreshTest extends DatabaseTestCase public function testAsPivot() { Schema::create('post_posts', function (Blueprint $table) { + $table->increments('id'); $table->bigInteger('foreign_id'); $table->bigInteger('related_id'); }); @@ -81,7 +77,7 @@ class Post extends Model { public $table = 'posts'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; use SoftDeletes; @@ -100,7 +96,7 @@ class AsPivotPost extends Post public function children() { return $this - ->belongsToMany(static::class, (new AsPivotPostPivot())->getTable(), 'foreign_id', 'related_id') + ->belongsToMany(static::class, (new AsPivotPostPivot)->getTable(), 'foreign_id', 'related_id') ->using(AsPivotPostPivot::class); } } diff --git a/tests/Integration/Database/EloquentModelScopeTest.php b/tests/Integration/Database/EloquentModelScopeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..408026738c2e4e3b5a1bf9e84697957ff7310d47 --- /dev/null +++ b/tests/Integration/Database/EloquentModelScopeTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Model; + +class EloquentModelScopeTest extends DatabaseTestCase +{ + public function testModelHasScope() + { + $model = new TestScopeModel1; + + $this->assertTrue($model->hasNamedScope('exists')); + } + + public function testModelDoesNotHaveScope() + { + $model = new TestScopeModel1; + + $this->assertFalse($model->hasNamedScope('doesNotExist')); + } +} + +class TestScopeModel1 extends Model +{ + public function scopeExists() + { + return true; + } +} diff --git a/tests/Integration/Database/EloquentModelStringCastingTest.php b/tests/Integration/Database/EloquentModelStringCastingTest.php index a22a824e125c690160754fe796d1e842ca841a8f..7ba208f37498cc34dcb8be9be556bb6cbff2b685 100644 --- a/tests/Integration/Database/EloquentModelStringCastingTest.php +++ b/tests/Integration/Database/EloquentModelStringCastingTest.php @@ -2,40 +2,16 @@ namespace Illuminate\Tests\Integration\Database; -use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Schema\Blueprint; -use PHPUnit\Framework\TestCase; +use Illuminate\Support\Facades\Schema; use stdClass; -/** - * @group integration - */ -class EloquentModelStringCastingTest extends TestCase +class EloquentModelStringCastingTest extends DatabaseTestCase { - protected function setUp(): void - { - $db = new DB; - - $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', - ]); - - $db->bootEloquent(); - $db->setAsGlobal(); - - $this->createSchema(); - } - - /** - * Setup the database schema. - * - * @return void - */ - public function createSchema() + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - $this->schema()->create('casting_table', function (Blueprint $table) { + Schema::create('casting_table', function (Blueprint $table) { $table->increments('id'); $table->string('array_attributes'); $table->string('json_attributes'); @@ -44,76 +20,46 @@ class EloquentModelStringCastingTest extends TestCase }); } - /** - * Tear down the database schema. - * - * @return void - */ - protected function tearDown(): void - { - $this->schema()->drop('casting_table'); - } - /** * Tests... */ public function testSavingCastedAttributesToDatabase() { - /** @var StringCasts $model */ + /** @var \Illuminate\Tests\Integration\Database\StringCasts $model */ $model = StringCasts::create([ - 'array_attributes' => ['key1'=>'value1'], - 'json_attributes' => ['json_key'=>'json_value'], - 'object_attributes' => ['json_key'=>'json_value'], + 'array_attributes' => ['key1' => 'value1'], + 'json_attributes' => ['json_key' => 'json_value'], + 'object_attributes' => ['json_key' => 'json_value'], ]); - $this->assertSame('{"key1":"value1"}', $model->getOriginal('array_attributes')); - $this->assertSame(['key1'=>'value1'], $model->getAttribute('array_attributes')); + $this->assertSame(['key1' => 'value1'], $model->getOriginal('array_attributes')); + $this->assertSame(['key1' => 'value1'], $model->getAttribute('array_attributes')); - $this->assertSame('{"json_key":"json_value"}', $model->getOriginal('json_attributes')); - $this->assertSame(['json_key'=>'json_value'], $model->getAttribute('json_attributes')); + $this->assertSame(['json_key' => 'json_value'], $model->getOriginal('json_attributes')); + $this->assertSame(['json_key' => 'json_value'], $model->getAttribute('json_attributes')); - $this->assertSame('{"json_key":"json_value"}', $model->getOriginal('object_attributes')); $stdClass = new stdClass; $stdClass->json_key = 'json_value'; + $this->assertEquals($stdClass, $model->getOriginal('object_attributes')); $this->assertEquals($stdClass, $model->getAttribute('object_attributes')); } public function testSavingCastedEmptyAttributesToDatabase() { - /** @var StringCasts $model */ + /** @var \Illuminate\Tests\Integration\Database\StringCasts $model */ $model = StringCasts::create([ 'array_attributes' => [], 'json_attributes' => [], 'object_attributes' => [], ]); - $this->assertSame('[]', $model->getOriginal('array_attributes')); + $this->assertSame([], $model->getOriginal('array_attributes')); $this->assertSame([], $model->getAttribute('array_attributes')); - $this->assertSame('[]', $model->getOriginal('json_attributes')); + $this->assertSame([], $model->getOriginal('json_attributes')); $this->assertSame([], $model->getAttribute('json_attributes')); - $this->assertSame('[]', $model->getOriginal('object_attributes')); + $this->assertSame([], $model->getOriginal('object_attributes')); $this->assertSame([], $model->getAttribute('object_attributes')); } - - /** - * Get a database connection instance. - * - * @return \Illuminate\Database\Connection - */ - protected function connection() - { - return Eloquent::getConnectionResolver()->connection(); - } - - /** - * Get a schema builder instance. - * - * @return \Illuminate\Database\Schema\Builder - */ - protected function schema() - { - return $this->connection()->getSchemaBuilder(); - } } /** @@ -127,7 +73,7 @@ class StringCasts extends Eloquent protected $table = 'casting_table'; /** - * @var array + * @var string[] */ protected $guarded = []; diff --git a/tests/Integration/Database/EloquentModelTest.php b/tests/Integration/Database/EloquentModelTest.php index f05e51944f25dbf22754f28d72e7acda27cb3bea..78edf007d762c6776a666bb77759265efe847a36 100644 --- a/tests/Integration/Database/EloquentModelTest.php +++ b/tests/Integration/Database/EloquentModelTest.php @@ -8,15 +8,10 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; -/** - * @group integration - */ class EloquentModelTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('test_model1', function (Blueprint $table) { $table->increments('id'); $table->timestamp('nullable_date')->nullable(); @@ -29,33 +24,6 @@ class EloquentModelTest extends DatabaseTestCase }); } - public function testCantUpdateGuardedAttributesUsingDifferentCasing() - { - $model = new TestModel2; - - $model->fill(['ID' => 123]); - - $this->assertNull($model->ID); - } - - public function testCantUpdateGuardedAttributeUsingJson() - { - $model = new TestModel2; - - $model->fill(['id->foo' => 123]); - - $this->assertNull($model->id); - } - - public function testCantMassFillAttributesWithTableNamesWhenUsingGuarded() - { - $model = new TestModel2; - - $model->fill(['foo.bar' => 123]); - - $this->assertCount(0, $model->getAttributes()); - } - public function testUserCanUpdateNullableDate() { $user = TestModel1::create([ @@ -102,13 +70,13 @@ class TestModel1 extends Model { public $table = 'test_model1'; public $timestamps = false; - protected $guarded = ['id']; - protected $dates = ['nullable_date']; + protected $guarded = []; + protected $casts = ['nullable_date' => 'datetime']; } class TestModel2 extends Model { public $table = 'test_model2'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; } diff --git a/tests/Integration/Database/EloquentModelWithoutEventsTest.php b/tests/Integration/Database/EloquentModelWithoutEventsTest.php index 7a15415f2bb89c4ba28f455a13fd53bc0e626612..07c4e68137ba6f1af60aab5f6632db31cb50eefb 100644 --- a/tests/Integration/Database/EloquentModelWithoutEventsTest.php +++ b/tests/Integration/Database/EloquentModelWithoutEventsTest.php @@ -6,15 +6,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentModelWithoutEventsTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('auto_filled_models', function (Blueprint $table) { $table->increments('id'); $table->text('project')->nullable(); @@ -31,7 +26,7 @@ class EloquentModelWithoutEventsTest extends DatabaseTestCase $model->save(); - $this->assertEquals('Laravel', $model->project); + $this->assertSame('Laravel', $model->project); } } @@ -39,7 +34,7 @@ class AutoFilledModel extends Model { public $table = 'auto_filled_models'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public static function boot() { diff --git a/tests/Integration/Database/EloquentMorphConstrainTest.php b/tests/Integration/Database/EloquentMorphConstrainTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0f244b4d096b14aed48394450eac49c66bc96b57 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphConstrainTest.php @@ -0,0 +1,88 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphConstrainTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphConstrainTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->boolean('post_visible'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + $table->boolean('video_visible'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post1 = Post::create(['post_visible' => true]); + (new Comment)->commentable()->associate($post1)->save(); + + $post2 = Post::create(['post_visible' => false]); + (new Comment)->commentable()->associate($post2)->save(); + + $video1 = Video::create(['video_visible' => true]); + (new Comment)->commentable()->associate($video1)->save(); + + $video2 = Video::create(['video_visible' => false]); + (new Comment)->commentable()->associate($video2)->save(); + } + + public function testMorphConstraints() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->constrain([ + Post::class => function ($query) { + $query->where('post_visible', true); + }, + Video::class => function ($query) { + $query->where('video_visible', true); + }, + ]); + }]) + ->get(); + + $this->assertTrue($comments[0]->commentable->post_visible); + $this->assertNull($comments[1]->commentable); + $this->assertTrue($comments[2]->commentable->video_visible); + $this->assertNull($comments[3]->commentable); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public $timestamps = false; + protected $fillable = ['post_visible']; + protected $casts = ['post_visible' => 'boolean']; +} + +class Video extends Model +{ + public $timestamps = false; + protected $fillable = ['video_visible']; + protected $casts = ['video_visible' => 'boolean']; +} diff --git a/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2692e23f001c94e4f20ff3e3d62b836150a92a28 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php @@ -0,0 +1,126 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphCountEagerLoadingTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphCountEagerLoadingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('views', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('video_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + $video = Video::create(); + + tap((new Like)->post()->associate($post))->save(); + tap((new Like)->post()->associate($post))->save(); + + tap((new View)->video()->associate($video))->save(); + + (new Comment)->commentable()->associate($post)->save(); + (new Comment)->commentable()->associate($video)->save(); + } + + public function testWithMorphCountLoading() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWithCount([Post::class => ['likes']]); + }]) + ->get(); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertEquals(2, $comments[0]->commentable->likes_count); + $this->assertTrue($comments[1]->relationLoaded('commentable')); + $this->assertNull($comments[1]->commentable->views_count); + } + + public function testWithMorphCountLoadingWithSingleRelation() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWithCount([Post::class => 'likes']); + }]) + ->get(); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertEquals(2, $comments[0]->commentable->likes_count); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public $timestamps = false; + + public function likes() + { + return $this->hasMany(Like::class); + } +} + +class Video extends Model +{ + public $timestamps = false; + + public function views() + { + return $this->hasMany(View::class); + } +} + +class Like extends Model +{ + public $timestamps = false; + + public function post() + { + return $this->belongsTo(Post::class); + } +} + +class View extends Model +{ + public $timestamps = false; + + public function video() + { + return $this->belongsTo(Video::class); + } +} diff --git a/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5ea6faf4621a2cbec033deebfef782c77d062098 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphCountLazyEagerLoadingTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphCountLazyEagerLoadingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + + tap((new Like)->post()->associate($post))->save(); + tap((new Like)->post()->associate($post))->save(); + + (new Comment)->commentable()->associate($post)->save(); + } + + public function testLazyEagerLoading() + { + $comment = Comment::first(); + + $comment->loadMorphCount('commentable', [ + Post::class => ['likes'], + ]); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertEquals(2, $comment->commentable->likes_count); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public $timestamps = false; + + public function likes() + { + return $this->hasMany(Like::class); + } +} + +class Like extends Model +{ + public $timestamps = false; + + public function post() + { + return $this->belongsTo(Post::class); + } +} diff --git a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php index 09a24b96d91484faa17efaa7568b1f2be315a3da..5a512d7a70691fedfccd0cf2a757dc77932f18d5 100644 --- a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphEagerLoadingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); }); diff --git a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..802083ae5c18c05c70e042451232c367240a05ac --- /dev/null +++ b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php @@ -0,0 +1,73 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphLazyEagerLoadingTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphLazyEagerLoadingTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('post_id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $user = User::create(); + + $post = tap((new Post)->user()->associate($user))->save(); + + (new Comment)->commentable()->associate($post)->save(); + } + + public function testLazyEagerLoading() + { + $comment = Comment::first(); + + $comment->loadMorph('commentable', [ + Post::class => ['user'], + ]); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertTrue($comment->commentable->relationLoaded('user')); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public $timestamps = false; + protected $primaryKey = 'post_id'; + + public function user() + { + return $this->belongsTo(User::class); + } +} + +class User extends Model +{ + public $timestamps = false; +} diff --git a/tests/Integration/Database/EloquentMorphManyTest.php b/tests/Integration/Database/EloquentMorphManyTest.php index 1ea9fbae1baea66918217d536c6b054c3a218bb9..04a22a565166b45ef5cd6f5e8f1c4b0ae4e7d707 100644 --- a/tests/Integration/Database/EloquentMorphManyTest.php +++ b/tests/Integration/Database/EloquentMorphManyTest.php @@ -9,15 +9,10 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphManyTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); @@ -62,7 +57,7 @@ class Post extends Model { public $table = 'posts'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; protected $withCount = ['comments']; public function comments() @@ -75,7 +70,7 @@ class Comment extends Model { public $table = 'comments'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; public function commentable() { diff --git a/tests/Integration/Database/EloquentMorphOneIsTest.php b/tests/Integration/Database/EloquentMorphOneIsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c28699a6382e4cb1b51beff726af1aeb14abc0fc --- /dev/null +++ b/tests/Integration/Database/EloquentMorphOneIsTest.php @@ -0,0 +1,100 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphOneIsTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphOneIsTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + }); + + Schema::create('attachments', function (Blueprint $table) { + $table->increments('id'); + $table->string('attachable_type')->nullable(); + $table->integer('attachable_id')->nullable(); + }); + + $post = Post::create(); + $post->attachment()->create(); + } + + public function testChildIsNotNull() + { + $parent = Post::first(); + $child = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsModel() + { + $parent = Post::first(); + $child = Attachment::first(); + + $this->assertTrue($parent->attachment()->is($child)); + $this->assertFalse($parent->attachment()->isNot($child)); + } + + public function testChildIsNotAnotherModel() + { + $parent = Post::first(); + $child = new Attachment; + $child->id = 2; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testNullChildIsNotModel() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->attachable_type = null; + $child->attachable_id = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherTable() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setTable('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherConnection() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setConnection('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } +} + +class Attachment extends Model +{ + public $timestamps = false; +} + +class Post extends Model +{ + public function attachment() + { + return $this->morphOne(Attachment::class, 'attachable'); + } +} diff --git a/tests/Integration/Database/EloquentMorphToGlobalScopesTest.php b/tests/Integration/Database/EloquentMorphToGlobalScopesTest.php index e9166bf696505e2603e29de6fc7aad6567c77372..6987d55f891efa0eec58acf3458faf5d29813d74 100644 --- a/tests/Integration/Database/EloquentMorphToGlobalScopesTest.php +++ b/tests/Integration/Database/EloquentMorphToGlobalScopesTest.php @@ -9,15 +9,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphToGlobalScopesTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->softDeletes(); diff --git a/tests/Integration/Database/EloquentMorphToIsTest.php b/tests/Integration/Database/EloquentMorphToIsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..41f80b82879fa3f83e05396911b2126aa221f967 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphToIsTest.php @@ -0,0 +1,101 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\EloquentMorphToIsTest; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class EloquentMorphToIsTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + (new Comment)->commentable()->associate($post)->save(); + } + + public function testParentIsNotNull() + { + $child = Comment::first(); + $parent = null; + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsModel() + { + $child = Comment::first(); + $parent = Post::first(); + + $this->assertTrue($child->commentable()->is($parent)); + $this->assertFalse($child->commentable()->isNot($parent)); + } + + public function testParentIsNotAnotherModel() + { + $child = Comment::first(); + $parent = new Post; + $parent->id = 2; + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testNullParentIsNotModel() + { + $child = Comment::first(); + $child->commentable()->dissociate(); + $parent = Post::first(); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherTable() + { + $child = Comment::first(); + $parent = Post::first(); + $parent->setTable('foo'); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherConnection() + { + $child = Comment::first(); + $parent = Post::first(); + $parent->setConnection('foo'); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + // +} diff --git a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php index 1f6c4319da432bc36b1339729efe46191e00da70..b6727024853c27557058a73c38ba7add9b694d72 100644 --- a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphToLazyEagerLoadingTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); }); diff --git a/tests/Integration/Database/EloquentMorphToSelectTest.php b/tests/Integration/Database/EloquentMorphToSelectTest.php index 0f0a6e91b1d4336a5a7299687657b338ed2185a2..6b1b736aea7ea3b67c87c6a78da9b2d9875af951 100644 --- a/tests/Integration/Database/EloquentMorphToSelectTest.php +++ b/tests/Integration/Database/EloquentMorphToSelectTest.php @@ -7,15 +7,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphToSelectTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->timestamps(); diff --git a/tests/Integration/Database/EloquentMorphToTouchesTest.php b/tests/Integration/Database/EloquentMorphToTouchesTest.php index 3fec94507de955ba380dd820972502e90fa86994..a4b4211e62cf0a6ed5b2f704a6b7f2990d110bc1 100644 --- a/tests/Integration/Database/EloquentMorphToTouchesTest.php +++ b/tests/Integration/Database/EloquentMorphToTouchesTest.php @@ -8,15 +8,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentMorphToTouchesTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->timestamps(); diff --git a/tests/Integration/Database/EloquentPaginateTest.php b/tests/Integration/Database/EloquentPaginateTest.php index 91409cd1cced5dd3998dcdff7478f0a57b536833..fa7768185e3c4a76224cd341db490076a45ad003 100644 --- a/tests/Integration/Database/EloquentPaginateTest.php +++ b/tests/Integration/Database/EloquentPaginateTest.php @@ -1,21 +1,15 @@ <?php -namespace Illuminate\Tests\Integration\Database\EloquentPaginateTest; +namespace Illuminate\Tests\Integration\Database; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentPaginateTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title')->nullable(); diff --git a/tests/Integration/Database/EloquentPivotEventsTest.php b/tests/Integration/Database/EloquentPivotEventsTest.php index 42f67e76d2f22d5b64c00f0e9081375eebddbbac..ea72a019d387a4898b3de1cf0f04d88483720cdc 100644 --- a/tests/Integration/Database/EloquentPivotEventsTest.php +++ b/tests/Integration/Database/EloquentPivotEventsTest.php @@ -7,15 +7,18 @@ use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentPivotEventsTest extends DatabaseTestCase { protected function setUp(): void { parent::setUp(); + // clear event log between requests + PivotEventsTestCollaborator::$eventsCalled = []; + } + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email'); @@ -31,11 +34,9 @@ class EloquentPivotEventsTest extends DatabaseTestCase Schema::create('project_users', function (Blueprint $table) { $table->integer('user_id'); $table->integer('project_id'); + $table->text('permissions')->nullable(); $table->string('role')->nullable(); }); - - // clear event log between requests - PivotEventsTestCollaborator::$eventsCalled = []; } public function testPivotWillTriggerEventsToBeFired() @@ -73,6 +74,52 @@ class EloquentPivotEventsTest extends DatabaseTestCase $project->contributors()->detach($user->id); $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); } + + public function testCustomPivotUpdateEventHasExistingAttributes() + { + $_SERVER['pivot_attributes'] = false; + + $user = PivotEventsTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = PivotEventsTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach($user, ['permissions' => ['foo', 'bar']]); + + $project->collaborators()->updateExistingPivot($user->id, ['role' => 'Lead Developer']); + + $this->assertEquals( + [ + 'user_id' => '1', + 'project_id' => '1', + 'permissions' => '["foo","bar"]', + 'role' => 'Lead Developer', + ], + $_SERVER['pivot_attributes'] + ); + } + + public function testCustomPivotUpdateEventHasDirtyCorrect() + { + $_SERVER['pivot_dirty_attributes'] = false; + + $user = PivotEventsTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = PivotEventsTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach($user, ['permissions' => ['foo', 'bar'], 'role' => 'Developer']); + + $project->collaborators()->updateExistingPivot($user->id, ['role' => 'Lead Developer']); + + $this->assertSame(['role' => 'Lead Developer'], $_SERVER['pivot_dirty_attributes']); + } } class PivotEventsTestUser extends Model @@ -103,6 +150,10 @@ class PivotEventsTestCollaborator extends Pivot { public $table = 'project_users'; + protected $casts = [ + 'permissions' => 'json', + ]; + public static $eventsCalled = []; public static function boot() @@ -122,6 +173,8 @@ class PivotEventsTestCollaborator extends Pivot }); static::updated(function ($model) { + $_SERVER['pivot_attributes'] = $model->getAttributes(); + $_SERVER['pivot_dirty_attributes'] = $model->getDirty(); static::$eventsCalled[] = 'updated'; }); diff --git a/tests/Integration/Database/EloquentPivotSerializationTest.php b/tests/Integration/Database/EloquentPivotSerializationTest.php index 131986146669a06a901b3cc6683f29cd58de4593..b3cbeaf0f03115247c5be6c8133c2e92ba0f13c6 100644 --- a/tests/Integration/Database/EloquentPivotSerializationTest.php +++ b/tests/Integration/Database/EloquentPivotSerializationTest.php @@ -10,15 +10,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentPivotSerializationTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email'); diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1c11b4c457be916ff42b2bffe3ca85f33f37e5cf --- /dev/null +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -0,0 +1,142 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Prunable; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Events\ModelsPruned; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use LogicException; + +/** @group SkipMSSQL */ +class EloquentPrunableTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + collect([ + 'prunable_test_models', + 'prunable_soft_delete_test_models', + 'prunable_test_model_missing_prunable_methods', + 'prunable_with_custom_prune_method_test_models', + ])->each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Please implement', + ); + + PrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + PrunableTestModel::insert($chunk->all()); + }); + + $count = (new PrunableTestModel)->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, PrunableTestModel::count()); + + Event::assertDispatched(ModelsPruned::class, 2); + } + + public function testPrunesSoftDeletedRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + PrunableSoftDeleteTestModel::insert($chunk->all()); + }); + + $count = (new PrunableSoftDeleteTestModel)->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, PrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, PrunableSoftDeleteTestModel::withTrashed()->count()); + + Event::assertDispatched(ModelsPruned::class, 3); + } + + public function testPruneWithCustomPruneMethod() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + PrunableWithCustomPruneMethodTestModel::insert($chunk->all()); + }); + + $count = (new PrunableWithCustomPruneMethodTestModel)->pruneAll(); + + $this->assertEquals(1000, $count); + $this->assertTrue((bool) PrunableWithCustomPruneMethodTestModel::first()->pruned); + $this->assertFalse((bool) PrunableWithCustomPruneMethodTestModel::orderBy('id', 'desc')->first()->pruned); + $this->assertEquals(5000, PrunableWithCustomPruneMethodTestModel::count()); + + Event::assertDispatched(ModelsPruned::class, 1); + } +} + +class PrunableTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class PrunableSoftDeleteTestModel extends Model +{ + use Prunable, SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class PrunableWithCustomPruneMethodTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1000); + } + + public function prune() + { + $this->forceFill([ + 'pruned' => true, + ])->save(); + } +} + +class PrunableTestModelMissingPrunableMethod extends Model +{ + use Prunable; +} diff --git a/tests/Integration/Database/EloquentPushTest.php b/tests/Integration/Database/EloquentPushTest.php index f34bbc207db7f3d9d3340f308f7b566e858e1769..090d3bb6c63b321cb5f277f0011ea60f6656b1e4 100644 --- a/tests/Integration/Database/EloquentPushTest.php +++ b/tests/Integration/Database/EloquentPushTest.php @@ -6,15 +6,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentPushTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('name'); diff --git a/tests/Integration/Database/EloquentStrictLoadingTest.php b/tests/Integration/Database/EloquentStrictLoadingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8342c68b843866f1f332de80e1839c1dcac1765c --- /dev/null +++ b/tests/Integration/Database/EloquentStrictLoadingTest.php @@ -0,0 +1,234 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\LazyLoadingViolationException; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class EloquentStrictLoadingTest extends DatabaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Model::preventLazyLoading(); + } + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('test_model1', function (Blueprint $table) { + $table->increments('id'); + $table->integer('number')->default(1); + }); + + Schema::create('test_model2', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('model_1_id'); + }); + + Schema::create('test_model3', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('model_2_id'); + }); + } + + public function testStrictModeThrowsAnExceptionOnLazyLoading() + { + $this->expectException(LazyLoadingViolationException::class); + $this->expectExceptionMessage('Attempted to lazy load'); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models[0]->modelTwos; + } + + public function testStrictModeDoesntThrowAnExceptionOnLazyLoadingWithSingleModel() + { + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $this->assertInstanceOf(Collection::class, $models); + } + + public function testStrictModeDoesntThrowAnExceptionOnAttributes() + { + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(['id']); + + $this->assertNull($models[0]->number); + } + + public function testStrictModeDoesntThrowAnExceptionOnEagerLoading() + { + $this->app['config']->set('database.connections.testing.zxc', false); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::with('modelTwos')->get(); + + $this->assertInstanceOf(Collection::class, $models[0]->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnLazyEagerLoading() + { + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models->load('modelTwos'); + + $this->assertInstanceOf(Collection::class, $models[0]->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnSingleModelLoading() + { + $model = EloquentStrictLoadingTestModel1::create(); + + $model = EloquentStrictLoadingTestModel1::find($model->id); + + $this->assertInstanceOf(Collection::class, $model->modelTwos); + } + + public function testStrictModeThrowsAnExceptionOnLazyLoadingInRelations() + { + $this->expectException(LazyLoadingViolationException::class); + $this->expectExceptionMessage('Attempted to lazy load'); + + $model1 = EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel2::create(['model_1_id' => $model1->id]); + EloquentStrictLoadingTestModel2::create(['model_1_id' => $model1->id]); + + $models = EloquentStrictLoadingTestModel1::with('modelTwos')->get(); + + $models[0]->modelTwos[0]->modelThrees; + } + + public function testStrictModeWithCustomCallbackOnLazyLoading() + { + $this->expectsEvents(ViolatedLazyLoadingEvent::class); + + Model::handleLazyLoadingViolationUsing(function ($model, $key) { + event(new ViolatedLazyLoadingEvent($model, $key)); + }); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models[0]->modelTwos; + + Model::handleLazyLoadingViolationUsing(null); + } + + public function testStrictModeWithOverriddenHandlerOnLazyLoading() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Violated'); + + EloquentStrictLoadingTestModel1WithCustomHandler::create(); + EloquentStrictLoadingTestModel1WithCustomHandler::create(); + + $models = EloquentStrictLoadingTestModel1WithCustomHandler::get(); + + $models[0]->modelTwos; + } + + public function testStrictModeDoesntThrowAnExceptionOnManuallyMadeModel() + { + $model1 = EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading::make(); + $model2 = EloquentStrictLoadingTestModel2::make(); + $model1->modelTwos->push($model2); + + $this->assertInstanceOf(Collection::class, $model1->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnRecentlyCreatedModel() + { + $model1 = EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading::create(); + $this->assertInstanceOf(Collection::class, $model1->modelTwos); + } +} + +class EloquentStrictLoadingTestModel1 extends Model +{ + public $table = 'test_model1'; + public $timestamps = false; + protected $guarded = []; + + public function modelTwos() + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } +} + +class EloquentStrictLoadingTestModel1WithCustomHandler extends Model +{ + public $table = 'test_model1'; + public $timestamps = false; + protected $guarded = []; + + public function modelTwos() + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } + + protected function handleLazyLoadingViolation($key) + { + throw new \RuntimeException("Violated {$key}"); + } +} + +class EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading extends Model +{ + public $table = 'test_model1'; + public $timestamps = false; + public $preventsLazyLoading = true; + protected $guarded = []; + + public function modelTwos() + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } +} + +class EloquentStrictLoadingTestModel2 extends Model +{ + public $table = 'test_model2'; + public $timestamps = false; + protected $guarded = []; + + public function modelThrees() + { + return $this->hasMany(EloquentStrictLoadingTestModel3::class, 'model_2_id'); + } +} + +class EloquentStrictLoadingTestModel3 extends Model +{ + public $table = 'test_model3'; + public $timestamps = false; + protected $guarded = []; +} + +class ViolatedLazyLoadingEvent +{ + public $model; + public $key; + + public function __construct($model, $key) + { + $this->model = $model; + $this->key = $key; + } +} diff --git a/tests/Integration/Database/EloquentTouchParentWithGlobalScopeTest.php b/tests/Integration/Database/EloquentTouchParentWithGlobalScopeTest.php index 646900fa2104d433016f7d4a182caff395b47676..551e568786d08b1303e6bbaaa9be91cfd4792e40 100644 --- a/tests/Integration/Database/EloquentTouchParentWithGlobalScopeTest.php +++ b/tests/Integration/Database/EloquentTouchParentWithGlobalScopeTest.php @@ -4,20 +4,14 @@ namespace Illuminate\Tests\Integration\Database\EloquentTouchParentWithGlobalSco use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentTouchParentWithGlobalScopeTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); @@ -30,8 +24,6 @@ class EloquentTouchParentWithGlobalScopeTest extends DatabaseTestCase $table->string('title'); $table->timestamps(); }); - - Carbon::setTestNow(null); } public function testBasicCreateAndRetrieve() @@ -50,7 +42,7 @@ class Post extends Model { public $table = 'posts'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; public function comments() { @@ -71,7 +63,7 @@ class Comment extends Model { public $table = 'comments'; public $timestamps = true; - protected $guarded = ['id']; + protected $guarded = []; protected $touches = ['post']; public function post() diff --git a/tests/Integration/Database/EloquentUpdateTest.php b/tests/Integration/Database/EloquentUpdateTest.php index 632d46d26d3bd2ba6422598adef0c3f53ac267ff..cc558faa47a26f09bcc193e48852dfd2c0139975 100644 --- a/tests/Integration/Database/EloquentUpdateTest.php +++ b/tests/Integration/Database/EloquentUpdateTest.php @@ -7,30 +7,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; -use Orchestra\Testbench\TestCase; -/** - * @group integration - */ -class EloquentUpdateTest extends TestCase +class EloquentUpdateTest extends DatabaseTestCase { - protected function getEnvironmentSetUp($app) + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function setUp(): void - { - parent::setUp(); - Schema::create('test_model1', function (Blueprint $table) { $table->increments('id'); $table->string('name')->nullable(); @@ -65,13 +46,14 @@ class EloquentUpdateTest extends TestCase $this->assertCount(0, TestUpdateModel1::all()); } + /** @group SkipMSSQL */ public function testUpdateWithLimitsAndOrders() { for ($i = 1; $i <= 10; $i++) { TestUpdateModel1::create(); } - TestUpdateModel1::latest('id')->limit(3)->update(['title'=>'Dr.']); + TestUpdateModel1::latest('id')->limit(3)->update(['title' => 'Dr.']); $this->assertSame('Dr.', TestUpdateModel1::find(8)->title); $this->assertNotSame('Dr.', TestUpdateModel1::find(7)->title); @@ -91,7 +73,7 @@ class EloquentUpdateTest extends TestCase TestUpdateModel2::join('test_model1', function ($join) { $join->on('test_model1.id', '=', 'test_model2.id') ->where('test_model1.title', '=', 'Mr.'); - })->update(['test_model2.name' => 'Abdul', 'job'=>'Engineer']); + })->update(['test_model2.name' => 'Abdul', 'job' => 'Engineer']); $record = TestUpdateModel2::find(1); @@ -129,7 +111,7 @@ class EloquentUpdateTest extends TestCase TestUpdateModel3::increment('counter'); - $models = TestUpdateModel3::withoutGlobalScopes()->get(); + $models = TestUpdateModel3::withoutGlobalScopes()->orderBy('id')->get(); $this->assertEquals(1, $models[0]->counter); $this->assertEquals(0, $models[1]->counter); } @@ -139,7 +121,7 @@ class TestUpdateModel1 extends Model { public $table = 'test_model1'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; } class TestUpdateModel2 extends Model @@ -156,5 +138,5 @@ class TestUpdateModel3 extends Model public $table = 'test_model3'; protected $fillable = ['counter']; - protected $dates = ['deleted_at']; + protected $casts = ['deleted_at' => 'datetime']; } diff --git a/tests/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php index 382ef5639ece8aa667358237dad721d3284d2153..a662ea73e04fb7d5d5c648a714eb994540b3ea03 100644 --- a/tests/Integration/Database/EloquentWhereHasMorphTest.php +++ b/tests/Integration/Database/EloquentWhereHasMorphTest.php @@ -10,15 +10,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentWhereHasMorphTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); @@ -56,7 +51,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase { $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 4], $comments->pluck('id')->all()); } @@ -70,7 +65,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase try { $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 4], $comments->pluck('id')->all()); } finally { @@ -86,7 +81,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase $comments = Comment::withTrashed() ->whereHasMorph('commentable', '*', function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 4], $comments->pluck('id')->all()); } @@ -100,9 +95,9 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase try { $comments = Comment::whereHasMorph('commentable', '*', function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); - $this->assertEquals([4, 1], $comments->pluck('id')->all()); + $this->assertEquals([1, 4], $comments->pluck('id')->all()); } finally { Relation::morphMap([], false); } @@ -112,7 +107,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase { $comments = Comment::whereHasMorph('commentableWithConstraint', Video::class, function (Builder $query) { $query->where('title', 'like', 'ba%'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([5], $comments->pluck('id')->all()); } @@ -127,7 +122,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase if ($type === Video::class) { $query->where('title', 'bar'); } - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 5], $comments->pluck('id')->all()); } @@ -138,6 +133,10 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase $table->string('slug')->nullable(); }); + Schema::table('comments', function (Blueprint $table) { + $table->dropIndex('comments_commentable_type_commentable_id_index'); + }); + Schema::table('comments', function (Blueprint $table) { $table->string('commentable_id')->change(); }); @@ -148,35 +147,35 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase $comments = Comment::whereHasMorph('commentableWithOwnerKey', Post::class, function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1], $comments->pluck('id')->all()); } public function testHasMorph() { - $comments = Comment::hasMorph('commentable', Post::class)->get(); + $comments = Comment::hasMorph('commentable', Post::class)->orderBy('id')->get(); $this->assertEquals([1, 2], $comments->pluck('id')->all()); } public function testOrHasMorph() { - $comments = Comment::where('id', 1)->orHasMorph('commentable', Video::class)->get(); + $comments = Comment::where('id', 1)->orHasMorph('commentable', Video::class)->orderBy('id')->get(); $this->assertEquals([1, 4, 5, 6], $comments->pluck('id')->all()); } public function testDoesntHaveMorph() { - $comments = Comment::doesntHaveMorph('commentable', Post::class)->get(); + $comments = Comment::doesntHaveMorph('commentable', Post::class)->orderBy('id')->get(); $this->assertEquals([3], $comments->pluck('id')->all()); } public function testOrDoesntHaveMorph() { - $comments = Comment::where('id', 1)->orDoesntHaveMorph('commentable', Post::class)->get(); + $comments = Comment::where('id', 1)->orDoesntHaveMorph('commentable', Post::class)->orderBy('id')->get(); $this->assertEquals([1, 3], $comments->pluck('id')->all()); } @@ -186,7 +185,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase $comments = Comment::where('id', 1) ->orWhereHasMorph('commentable', Video::class, function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 4], $comments->pluck('id')->all()); } @@ -195,7 +194,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase { $comments = Comment::whereDoesntHaveMorph('commentable', Post::class, function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([2, 3], $comments->pluck('id')->all()); } @@ -205,7 +204,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase $comments = Comment::where('id', 1) ->orWhereDoesntHaveMorph('commentable', Post::class, function (Builder $query) { $query->where('title', 'foo'); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 2, 3], $comments->pluck('id')->all()); } @@ -214,7 +213,7 @@ class EloquentWhereHasMorphTest extends DatabaseTestCase { $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { $query->someSharedModelScope(); - })->get(); + })->orderBy('id')->get(); $this->assertEquals([1, 4], $comments->pluck('id')->all()); } @@ -226,7 +225,7 @@ class Comment extends Model public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function commentable() { @@ -250,7 +249,7 @@ class Post extends Model public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function scopeSomeSharedModelScope($query) { @@ -262,7 +261,7 @@ class Video extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function scopeSomeSharedModelScope($query) { diff --git a/tests/Integration/Database/EloquentWhereHasTest.php b/tests/Integration/Database/EloquentWhereHasTest.php index 071c57a7d2b7f99e918feafdfc6d7636909cc467..9874870f9f7ed4a38d06b738fbb7f3f78ea1690f 100644 --- a/tests/Integration/Database/EloquentWhereHasTest.php +++ b/tests/Integration/Database/EloquentWhereHasTest.php @@ -7,15 +7,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentWhereHasTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); }); @@ -26,6 +21,12 @@ class EloquentWhereHasTest extends DatabaseTestCase $table->boolean('public'); }); + Schema::create('texts', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + $table->text('content'); + }); + Schema::create('comments', function (Blueprint $table) { $table->increments('id'); $table->string('commentable_type'); @@ -35,10 +36,56 @@ class EloquentWhereHasTest extends DatabaseTestCase $user = User::create(); $post = tap((new Post(['public' => true]))->user()->associate($user))->save(); (new Comment)->commentable()->associate($post)->save(); + (new Text(['content' => 'test']))->post()->associate($post)->save(); $user = User::create(); $post = tap((new Post(['public' => false]))->user()->associate($user))->save(); (new Comment)->commentable()->associate($post)->save(); + (new Text(['content' => 'test2']))->post()->associate($post)->save(); + } + + public function testWhereRelation() + { + $users = User::whereRelation('posts', 'public', true)->get(); + + $this->assertEquals([1], $users->pluck('id')->all()); + } + + public function testOrWhereRelation() + { + $users = User::whereRelation('posts', 'public', true)->orWhereRelation('posts', 'public', false)->get(); + + $this->assertEquals([1, 2], $users->pluck('id')->all()); + } + + public function testNestedWhereRelation() + { + $texts = User::whereRelation('posts.texts', 'content', 'test')->get(); + + $this->assertEquals([1], $texts->pluck('id')->all()); + } + + public function testNestedOrWhereRelation() + { + $texts = User::whereRelation('posts.texts', 'content', 'test')->orWhereRelation('posts.texts', 'content', 'test2')->get(); + + $this->assertEquals([1, 2], $texts->pluck('id')->all()); + } + + public function testWhereMorphRelation() + { + $comments = Comment::whereMorphRelation('commentable', '*', 'public', true)->get(); + + $this->assertEquals([1], $comments->pluck('id')->all()); + } + + public function testOrWhereMorphRelation() + { + $comments = Comment::whereMorphRelation('commentable', '*', 'public', true) + ->orWhereMorphRelation('commentable', '*', 'public', false) + ->get(); + + $this->assertEquals([1, 2], $comments->pluck('id')->all()); } public function testWithCount() @@ -74,12 +121,29 @@ class Post extends Model return $this->morphMany(Comment::class, 'commentable'); } + public function texts() + { + return $this->hasMany(Text::class); + } + public function user() { return $this->belongsTo(User::class); } } +class Text extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + public function post() + { + return $this->belongsTo(Post::class); + } +} + class User extends Model { public $timestamps = false; diff --git a/tests/Integration/Database/EloquentWhereTest.php b/tests/Integration/Database/EloquentWhereTest.php index 3600dc42d26938b44a606edcc2e218aa44ed84cd..f99b9cf710cedeb5a9235076ecf1e41483dc0ca9 100644 --- a/tests/Integration/Database/EloquentWhereTest.php +++ b/tests/Integration/Database/EloquentWhereTest.php @@ -3,18 +3,16 @@ namespace Illuminate\Tests\Integration\Database; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\MultipleRecordsFoundException; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -/** - * @group integration - */ class EloquentWhereTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('name'); @@ -25,14 +23,14 @@ class EloquentWhereTest extends DatabaseTestCase public function testWhereAndWhereOrBehavior() { - /** @var UserWhereTest $firstUser */ + /** @var \Illuminate\Tests\Integration\Database\UserWhereTest $firstUser */ $firstUser = UserWhereTest::create([ 'name' => 'test-name', 'email' => 'test-email', 'address' => 'test-address', ]); - /** @var UserWhereTest $secondUser */ + /** @var \Illuminate\Tests\Integration\Database\UserWhereTest $secondUser */ $secondUser = UserWhereTest::create([ 'name' => 'test-name1', 'email' => 'test-email1', @@ -67,14 +65,14 @@ class EloquentWhereTest extends DatabaseTestCase public function testFirstWhere() { - /** @var UserWhereTest $firstUser */ + /** @var \Illuminate\Tests\Integration\Database\UserWhereTest $firstUser */ $firstUser = UserWhereTest::create([ 'name' => 'test-name', 'email' => 'test-email', 'address' => 'test-address', ]); - /** @var UserWhereTest $secondUser */ + /** @var \Illuminate\Tests\Integration\Database\UserWhereTest $secondUser */ $secondUser = UserWhereTest::create([ 'name' => 'test-name1', 'email' => 'test-email1', @@ -91,6 +89,73 @@ class EloquentWhereTest extends DatabaseTestCase UserWhereTest::firstWhere(['name' => 'wrong-name', 'email' => 'test-email1'], null, null, 'or')) ); } + + public function testSole() + { + $expected = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + $this->assertTrue($expected->is(UserWhereTest::where('name', 'test-name')->sole())); + } + + public function testSoleFailsForMultipleRecords() + { + UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'other-email', + 'address' => 'other-address', + ]); + + $this->expectException(MultipleRecordsFoundException::class); + + UserWhereTest::where('name', 'test-name')->sole(); + } + + public function testSoleFailsIfNoRecords() + { + try { + UserWhereTest::where('name', 'test-name')->sole(); + } catch (ModelNotFoundException $exception) { + // + } + + $this->assertSame(UserWhereTest::class, $exception->getModel()); + } + + public function testChunkMap() + { + UserWhereTest::create([ + 'name' => 'first-name', + 'email' => 'first-email', + 'address' => 'first-address', + ]); + + UserWhereTest::create([ + 'name' => 'second-name', + 'email' => 'second-email', + 'address' => 'second-address', + ]); + + DB::enableQueryLog(); + + $results = UserWhereTest::orderBy('id')->chunkMap(function ($user) { + return $user->name; + }, 1); + + $this->assertCount(2, $results); + $this->assertSame('first-name', $results[0]); + $this->assertSame('second-name', $results[1]); + $this->assertCount(3, DB::getQueryLog()); + } } class UserWhereTest extends Model diff --git a/tests/Integration/Database/EloquentWithCountTest.php b/tests/Integration/Database/EloquentWithCountTest.php index bcb65056a1e3dac1d399056d914f8825b5c37cf0..5174177ab0c55a84725fac8b2d42b2f84714543f 100644 --- a/tests/Integration/Database/EloquentWithCountTest.php +++ b/tests/Integration/Database/EloquentWithCountTest.php @@ -7,15 +7,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class EloquentWithCountTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('one', function (Blueprint $table) { $table->increments('id'); }); @@ -64,13 +59,24 @@ class EloquentWithCountTest extends DatabaseTestCase $result = Model1::withCount('allFours')->first(); $this->assertEquals(1, $result->all_fours_count); } + + public function testSortingScopes() + { + $one = Model1::create(); + $one->twos()->create(); + + $query = Model1::withCount('twos')->getQuery(); + + $this->assertNull($query->orders); + $this->assertSame([], $query->getRawBindings()['order']); + } } class Model1 extends Model { public $table = 'one'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function twos() { @@ -92,9 +98,18 @@ class Model2 extends Model { public $table = 'two'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; protected $withCount = ['threes']; + protected static function boot() + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->latest(); + }); + } + public function threes() { return $this->hasMany(Model3::class, 'two_id'); @@ -105,14 +120,14 @@ class Model3 extends Model { public $table = 'three'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; protected static function boot() { parent::boot(); static::addGlobalScope('app', function ($builder) { - $builder->where('idz', '>', 0); + $builder->where('id', '>', 0); }); } } @@ -121,7 +136,7 @@ class Model4 extends Model { public $table = 'four'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; protected static function boot() { diff --git a/tests/Integration/Database/Enums.php b/tests/Integration/Database/Enums.php new file mode 100644 index 0000000000000000000000000000000000000000..fc466716533a10e0e73fa35e1334768a3b553bb4 --- /dev/null +++ b/tests/Integration/Database/Enums.php @@ -0,0 +1,40 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Contracts\Support\Arrayable; + +enum StringStatus: string +{ + case pending = 'pending'; + case done = 'done'; +} + +enum IntegerStatus: int +{ + case pending = 1; + case done = 2; +} + +enum ArrayableStatus: string implements Arrayable +{ + case pending = 'pending'; + case done = 'done'; + + public function description(): string + { + return match ($this) { + self::pending => 'pending status description', + self::done => 'done status description' + }; + } + + public function toArray() + { + return [ + 'name' => $this->name, + 'value' => $this->value, + 'description' => $this->description(), + ]; + } +} diff --git a/tests/Integration/Database/MigrateWithRealpathTest.php b/tests/Integration/Database/MigrateWithRealpathTest.php index edc87f7fdec56add160a8df84e4b6c395a678162..5e3da7439fe67ae7f2b5e76af96784ffb58d0e19 100644 --- a/tests/Integration/Database/MigrateWithRealpathTest.php +++ b/tests/Integration/Database/MigrateWithRealpathTest.php @@ -3,13 +3,18 @@ namespace Illuminate\Tests\Integration\Database; use Illuminate\Support\Facades\Schema; +use Orchestra\Testbench\TestCase; -class MigrateWithRealpathTest extends DatabaseTestCase +class MigrateWithRealpathTest extends TestCase { protected function setUp(): void { parent::setUp(); + if ($this->app['config']->get('database.default') !== 'testing') { + $this->artisan('db:wipe', ['--drop-views' => true]); + } + $options = [ '--path' => realpath(__DIR__.'/stubs/'), '--realpath' => true, diff --git a/tests/Integration/Database/MigratorEventsTest.php b/tests/Integration/Database/MigratorEventsTest.php index 75d3ac6327daeff4c534f32ff374206f7bc387ca..46e2d49ffd7c25936dc5b96060c8e66ae752fbde 100644 --- a/tests/Integration/Database/MigratorEventsTest.php +++ b/tests/Integration/Database/MigratorEventsTest.php @@ -9,8 +9,9 @@ use Illuminate\Database\Events\MigrationStarted; use Illuminate\Database\Events\NoPendingMigrations; use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\Event; +use Orchestra\Testbench\TestCase; -class MigratorEventsTest extends DatabaseTestCase +class MigratorEventsTest extends TestCase { protected function migrateOptions() { @@ -40,17 +41,30 @@ class MigratorEventsTest extends DatabaseTestCase $this->artisan('migrate', $this->migrateOptions()); $this->artisan('migrate:rollback', $this->migrateOptions()); + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'up'; + }); + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'down'; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'up'; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'down'; + }); + Event::assertDispatched(MigrationStarted::class, function ($event) { - return $event->method == 'up' && $event->migration instanceof Migration; + return $event->method === 'up' && $event->migration instanceof Migration; }); Event::assertDispatched(MigrationStarted::class, function ($event) { - return $event->method == 'down' && $event->migration instanceof Migration; + return $event->method === 'down' && $event->migration instanceof Migration; }); Event::assertDispatched(MigrationEnded::class, function ($event) { - return $event->method == 'up' && $event->migration instanceof Migration; + return $event->method === 'up' && $event->migration instanceof Migration; }); Event::assertDispatched(MigrationEnded::class, function ($event) { - return $event->method == 'down' && $event->migration instanceof Migration; + return $event->method === 'down' && $event->migration instanceof Migration; }); } @@ -62,10 +76,10 @@ class MigratorEventsTest extends DatabaseTestCase $this->artisan('migrate:rollback'); Event::assertDispatched(NoPendingMigrations::class, function ($event) { - return $event->method == 'up'; + return $event->method === 'up'; }); Event::assertDispatched(NoPendingMigrations::class, function ($event) { - return $event->method == 'down'; + return $event->method === 'down'; }); } } diff --git a/tests/Integration/Database/DatabaseEmulatePreparesMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php similarity index 68% rename from tests/Integration/Database/DatabaseEmulatePreparesMySqlConnectionTest.php rename to tests/Integration/Database/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php index bd71a5865c29b57085b5fc1395df48f9cd7c640f..9848da4a642b07850785d55f20be88ac865ac848 100755 --- a/tests/Integration/Database/DatabaseEmulatePreparesMySqlConnectionTest.php +++ b/tests/Integration/Database/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php @@ -1,18 +1,19 @@ <?php -namespace Illuminate\Tests\Integration\Database; +namespace Illuminate\Tests\Integration\Database\MySql; use PDO; /** * @requires extension pdo_mysql + * @requires OS Linux|Darwin */ class DatabaseEmulatePreparesMySqlConnectionTest extends DatabaseMySqlConnectionTest { protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - $app['config']->set('database.default', 'mysql'); + parent::getEnvironmentSetUp($app); + $app['config']->set('database.connections.mysql.options', [ PDO::ATTR_EMULATE_PREPARES => true, ]); diff --git a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5119cd3b4233e09b7953434e3d7abe67692fca9b --- /dev/null +++ b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php @@ -0,0 +1,104 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\MySql; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +/** + * @requires extension pdo_mysql + * @requires OS Linux|Darwin + */ +class DatabaseMySqlConnectionTest extends MySqlTestCase +{ + const TABLE = 'player'; + const FLOAT_COL = 'float_col'; + const JSON_COL = 'json_col'; + const FLOAT_VAL = 0.2; + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + if (! Schema::hasTable(self::TABLE)) { + Schema::create(self::TABLE, function (Blueprint $table) { + $table->json(self::JSON_COL)->nullable(); + $table->float(self::FLOAT_COL)->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop(self::TABLE); + } + + /** + * @dataProvider floatComparisonsDataProvider + */ + public function testJsonFloatComparison($value, $operator, $shouldMatch) + { + DB::table(self::TABLE)->insert([self::JSON_COL => '{"rank":'.self::FLOAT_VAL.'}']); + + $this->assertSame( + $shouldMatch, + DB::table(self::TABLE)->where(self::JSON_COL.'->rank', $operator, $value)->exists(), + self::JSON_COL.'->rank should '.($shouldMatch ? '' : 'not ')."be $operator $value" + ); + } + + public function floatComparisonsDataProvider() + { + return [ + [0.2, '=', true], + [0.2, '>', false], + [0.2, '<', false], + [0.1, '=', false], + [0.1, '<', false], + [0.1, '>', true], + [0.3, '=', false], + [0.3, '<', true], + [0.3, '>', false], + ]; + } + + public function testFloatValueStoredCorrectly() + { + DB::table(self::TABLE)->insert([self::FLOAT_COL => self::FLOAT_VAL]); + + $this->assertEquals(self::FLOAT_VAL, DB::table(self::TABLE)->value(self::FLOAT_COL)); + } + + /** + * @dataProvider jsonWhereNullDataProvider + */ + public function testJsonWhereNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame($expected, DB::table(self::TABLE)->whereNull(self::JSON_COL.'->'.$key)->exists()); + } + + /** + * @dataProvider jsonWhereNullDataProvider + */ + public function testJsonWhereNotNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame(! $expected, DB::table(self::TABLE)->whereNotNull(self::JSON_COL.'->'.$key)->exists()); + } + + public function jsonWhereNullDataProvider() + { + return [ + 'key not exists' => [true, 'invalid'], + 'key exists and null' => [true, 'value', ['value' => null]], + 'key exists and "null"' => [false, 'value', ['value' => 'null']], + 'key exists and not null' => [false, 'value', ['value' => false]], + 'nested key not exists' => [true, 'nested->invalid'], + 'nested key exists and null' => [true, 'nested->value', ['nested' => ['value' => null]]], + 'nested key exists and "null"' => [false, 'nested->value', ['nested' => ['value' => 'null']]], + 'nested key exists and not null' => [false, 'nested->value', ['nested' => ['value' => false]]], + ]; + } +} diff --git a/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php b/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php new file mode 100644 index 0000000000000000000000000000000000000000..18b156dc98dadea6273fb83c9302ef181d960e47 --- /dev/null +++ b/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php @@ -0,0 +1,75 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\MySql; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use stdClass; + +/** + * @requires extension pdo_mysql + * @requires OS Linux|Darwin + */ +class DatabaseMySqlSchemaBuilderAlterTableWithEnumTest extends MySqlTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + $table->string('age'); + $table->enum('color', ['red', 'blue']); + }); + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('users'); + } + + public function testRenameColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('name', 'username'); + }); + + $this->assertTrue(Schema::hasColumn('users', 'username')); + } + + public function testChangeColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->unsignedInteger('age')->charset('')->change(); + }); + + $this->assertSame('integer', Schema::getColumnType('users', 'age')); + } + + public function testGetAllTablesAndColumnListing() + { + $tables = Schema::getAllTables(); + + $this->assertCount(2, $tables); + $tableProperties = array_values((array) $tables[0]); + $this->assertEquals(['migrations', 'BASE TABLE'], $tableProperties); + + $this->assertInstanceOf(stdClass::class, $tables[1]); + + $tableProperties = array_values((array) $tables[1]); + $this->assertEquals(['users', 'BASE TABLE'], $tableProperties); + + $columns = Schema::getColumnListing('users'); + + foreach (['id', 'name', 'age', 'color'] as $column) { + $this->assertContains($column, $columns); + } + + Schema::create('posts', function (Blueprint $table) { + $table->integer('id'); + $table->string('title'); + }); + $tables = Schema::getAllTables(); + $this->assertCount(3, $tables); + Schema::drop('posts'); + } +} diff --git a/tests/Integration/Database/MySql/FulltextTest.php b/tests/Integration/Database/MySql/FulltextTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a98d0c74b48ace33f02126136584faa010256232 --- /dev/null +++ b/tests/Integration/Database/MySql/FulltextTest.php @@ -0,0 +1,69 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\MySql; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +/** + * @requires extension pdo_mysql + * @requires OS Linux|Darwin + */ +class FulltextTest extends MySqlTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('articles', function (Blueprint $table) { + $table->id('id'); + $table->string('title', 200); + $table->text('body'); + $table->fulltext(['title', 'body']); + }); + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('articles'); + } + + protected function setUp(): void + { + parent::setUp(); + + DB::table('articles')->insert([ + ['title' => 'MySQL Tutorial', 'body' => 'DBMS stands for DataBase ...'], + ['title' => 'How To Use MySQL Well', 'body' => 'After you went through a ...'], + ['title' => 'Optimizing MySQL', 'body' => 'In this tutorial, we show ...'], + ['title' => '1001 MySQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'], + ['title' => 'MySQL vs. YourSQL', 'body' => 'In the following database comparison ...'], + ['title' => 'MySQL Security', 'body' => 'When configured properly, MySQL ...'], + ]); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html */ + public function testWhereFulltext() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], 'database')->get(); + + $this->assertCount(2, $articles); + $this->assertSame('MySQL Tutorial', $articles[0]->title); + $this->assertSame('MySQL vs. YourSQL', $articles[1]->title); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html */ + public function testWhereFulltextWithBooleanMode() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], '+MySQL -YourSQL', ['mode' => 'boolean'])->get(); + + $this->assertCount(5, $articles); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-query-expansion.html */ + public function testWhereFulltextWithExpandedQuery() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], 'database', ['expanded' => true])->get(); + + $this->assertCount(6, $articles); + } +} diff --git a/tests/Integration/Database/MySql/MySqlTestCase.php b/tests/Integration/Database/MySql/MySqlTestCase.php new file mode 100644 index 0000000000000000000000000000000000000000..164320e14f8f2c9cf1c55ebf0b39b50b314cfcf5 --- /dev/null +++ b/tests/Integration/Database/MySql/MySqlTestCase.php @@ -0,0 +1,15 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\MySql; + +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +abstract class MySqlTestCase extends DatabaseTestCase +{ + protected function defineDatabaseMigrations() + { + if ($this->driver !== 'mysql') { + $this->markTestSkipped('Test requires a MySQL connection.'); + } + } +} diff --git a/tests/Integration/Database/Postgres/FulltextTest.php b/tests/Integration/Database/Postgres/FulltextTest.php new file mode 100644 index 0000000000000000000000000000000000000000..39ddb6837022bd71cfeb479e46f30ea05125de70 --- /dev/null +++ b/tests/Integration/Database/Postgres/FulltextTest.php @@ -0,0 +1,73 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\Postgres; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +/** + * @requires extension pdo_pgsql + * @requires OS Linux|Darwin + */ +class FulltextTest extends PostgresTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('articles', function (Blueprint $table) { + $table->id('id'); + $table->string('title', 200); + $table->text('body'); + $table->fulltext(['title', 'body']); + }); + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('articles'); + } + + protected function setUp(): void + { + parent::setUp(); + + DB::table('articles')->insert([ + ['title' => 'PostgreSQL Tutorial', 'body' => 'DBMS stands for DataBase ...'], + ['title' => 'How To Use PostgreSQL Well', 'body' => 'After you went through a ...'], + ['title' => 'Optimizing PostgreSQL', 'body' => 'In this tutorial, we show ...'], + ['title' => '1001 PostgreSQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'], + ['title' => 'PostgreSQL vs. YourSQL', 'body' => 'In the following database comparison ...'], + ['title' => 'PostgreSQL Security', 'body' => 'When configured properly, PostgreSQL ...'], + ]); + } + + public function testWhereFulltext() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], 'database')->orderBy('id')->get(); + + $this->assertCount(2, $articles); + $this->assertSame('PostgreSQL Tutorial', $articles[0]->title); + $this->assertSame('PostgreSQL vs. YourSQL', $articles[1]->title); + } + + public function testWhereFulltextWithWebsearch() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], '+PostgreSQL -YourSQL', ['mode' => 'websearch'])->get(); + + $this->assertCount(5, $articles); + } + + public function testWhereFulltextWithPlain() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'plain'])->get(); + + $this->assertCount(2, $articles); + } + + public function testWhereFulltextWithPhrase() + { + $articles = DB::table('articles')->whereFulltext(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'phrase'])->get(); + + $this->assertCount(1, $articles); + } +} diff --git a/tests/Integration/Database/Postgres/PostgresTestCase.php b/tests/Integration/Database/Postgres/PostgresTestCase.php new file mode 100644 index 0000000000000000000000000000000000000000..9b06d1a36e85510f46ff13a3995eea3c06c082b8 --- /dev/null +++ b/tests/Integration/Database/Postgres/PostgresTestCase.php @@ -0,0 +1,15 @@ +<?php + +namespace Illuminate\Tests\Integration\Database\Postgres; + +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +abstract class PostgresTestCase extends DatabaseTestCase +{ + protected function defineDatabaseMigrations() + { + if ($this->driver !== 'pgsql') { + $this->markTestSkipped('Test requires a PostgreSQL connection.'); + } + } +} diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index 5a05a3f2d5235b1c0c3f205737ca948840086d10..ae1d5ea616522600afc3b8ec4ddfce74b607efcd 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -1,23 +1,19 @@ <?php -namespace Illuminate\Tests\Integration\Database\EloquentBelongsToManyTest; +namespace Illuminate\Tests\Integration\Database; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\MultipleRecordsFoundException; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\DatabaseTestCase; -/** - * @group integration - */ class QueryBuilderTest extends DatabaseTestCase { - protected function setUp(): void + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { - parent::setUp(); - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); @@ -31,17 +27,42 @@ class QueryBuilderTest extends DatabaseTestCase ]); } + public function testSole() + { + $expected = ['id' => '1', 'title' => 'Foo Post']; + + $this->assertEquals($expected, (array) DB::table('posts')->where('title', 'Foo Post')->select('id', 'title')->sole()); + } + + public function testSoleFailsForMultipleRecords() + { + DB::table('posts')->insert([ + ['title' => 'Foo Post', 'content' => 'Lorem Ipsum.', 'created_at' => new Carbon('2017-11-12 13:14:15')], + ]); + + $this->expectException(MultipleRecordsFoundException::class); + + DB::table('posts')->where('title', 'Foo Post')->sole(); + } + + public function testSoleFailsIfNoRecords() + { + $this->expectException(RecordsNotFoundException::class); + + DB::table('posts')->where('title', 'Baz Post')->sole(); + } + public function testSelect() { $expected = ['id' => '1', 'title' => 'Foo Post']; - $this->assertSame($expected, (array) DB::table('posts')->select('id', 'title')->first()); - $this->assertSame($expected, (array) DB::table('posts')->select(['id', 'title'])->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select('id', 'title')->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select(['id', 'title'])->first()); } public function testSelectReplacesExistingSelects() { - $this->assertSame( + $this->assertEquals( ['id' => '1', 'title' => 'Foo Post'], (array) DB::table('posts')->select('content')->select(['id', 'title'])->first() ); @@ -49,10 +70,10 @@ class QueryBuilderTest extends DatabaseTestCase public function testSelectWithSubQuery() { - $this->assertSame( - ['id' => '1', 'title' => 'Foo Post', 'foo' => 'bar'], + $this->assertEquals( + ['id' => '1', 'title' => 'Foo Post', 'foo' => 'Lorem Ipsum.'], (array) DB::table('posts')->select(['id', 'title', 'foo' => function ($query) { - $query->select('bar'); + $query->select('content'); }])->first() ); } @@ -61,24 +82,24 @@ class QueryBuilderTest extends DatabaseTestCase { $expected = ['id' => '1', 'title' => 'Foo Post', 'content' => 'Lorem Ipsum.']; - $this->assertSame($expected, (array) DB::table('posts')->select('id')->addSelect('title', 'content')->first()); - $this->assertSame($expected, (array) DB::table('posts')->select('id')->addSelect(['title', 'content'])->first()); - $this->assertSame($expected, (array) DB::table('posts')->addSelect(['id', 'title', 'content'])->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select('id')->addSelect('title', 'content')->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select('id')->addSelect(['title', 'content'])->first()); + $this->assertEquals($expected, (array) DB::table('posts')->addSelect(['id', 'title', 'content'])->first()); } public function testAddSelectWithSubQuery() { - $this->assertSame( - ['id' => '1', 'title' => 'Foo Post', 'foo' => 'bar'], + $this->assertEquals( + ['id' => '1', 'title' => 'Foo Post', 'foo' => 'Lorem Ipsum.'], (array) DB::table('posts')->addSelect(['id', 'title', 'foo' => function ($query) { - $query->select('bar'); + $query->select('content'); }])->first() ); } public function testFromWithAlias() { - $this->assertSame('select * from "posts" as "alias"', DB::table('posts', 'alias')->toSql()); + $this->assertCount(2, DB::table('posts', 'alias')->select('alias.*')->get()); } public function testFromWithSubQuery() @@ -91,6 +112,26 @@ class QueryBuilderTest extends DatabaseTestCase ); } + public function testWhereValueSubQuery() + { + $subQuery = function ($query) { + $query->selectRaw("'Sub query value'"); + }; + + $this->assertTrue(DB::table('posts')->where($subQuery, 'Sub query value')->exists()); + $this->assertFalse(DB::table('posts')->where($subQuery, 'Does not match')->exists()); + $this->assertTrue(DB::table('posts')->where($subQuery, '!=', 'Does not match')->exists()); + } + + public function testWhereValueSubQueryBuilder() + { + $subQuery = DB::table('posts')->selectRaw("'Sub query value'")->limit(1); + + $this->assertTrue(DB::table('posts')->where($subQuery, 'Sub query value')->exists()); + $this->assertFalse(DB::table('posts')->where($subQuery, 'Does not match')->exists()); + $this->assertTrue(DB::table('posts')->where($subQuery, '!=', 'Does not match')->exists()); + } + public function testWhereDate() { $this->assertSame(1, DB::table('posts')->whereDate('created_at', '2018-01-02')->count()); @@ -167,4 +208,18 @@ class QueryBuilderTest extends DatabaseTestCase (object) ['title' => 'Bar Post', 'content' => 'Lorem Ipsum.'], ]); } + + public function testChunkMap() + { + DB::enableQueryLog(); + + $results = DB::table('posts')->orderBy('id')->chunkMap(function ($post) { + return $post->title; + }, 1); + + $this->assertCount(2, $results); + $this->assertSame('Foo Post', $results[0]); + $this->assertSame('Bar Post', $results[1]); + $this->assertCount(3, DB::getQueryLog()); + } } diff --git a/tests/Integration/Database/QueryingWithEnumsTest.php b/tests/Integration/Database/QueryingWithEnumsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..84c352eb00a632b612cf266fe7a45d4e23acb59b --- /dev/null +++ b/tests/Integration/Database/QueryingWithEnumsTest.php @@ -0,0 +1,58 @@ +<?php + +namespace Illuminate\Tests\Integration\Database; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +if (PHP_VERSION_ID >= 80100) { + include_once 'Enums.php'; +} + +/** + * @requires PHP >= 8.1 + */ +class QueryingWithEnumsTest extends DatabaseTestCase +{ + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + Schema::create('enum_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('string_status', 100)->nullable(); + $table->integer('integer_status')->nullable(); + }); + } + + public function testCanQueryWithEnums() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + ]); + + $record = DB::table('enum_casts')->where('string_status', StringStatus::pending)->first(); + $record2 = DB::table('enum_casts')->where('integer_status', IntegerStatus::pending)->first(); + $record3 = DB::table('enum_casts')->whereIn('integer_status', [IntegerStatus::pending])->first(); + + $this->assertNotNull($record); + $this->assertNotNull($record2); + $this->assertNotNull($record3); + $this->assertEquals('pending', $record->string_status); + $this->assertEquals(1, $record2->integer_status); + } + + public function testCanInsertWithEnums() + { + DB::table('enum_casts')->insert([ + 'string_status' => StringStatus::pending, + 'integer_status' => IntegerStatus::pending, + ]); + + $record = DB::table('enum_casts')->where('string_status', StringStatus::pending)->first(); + + $this->assertNotNull($record); + $this->assertEquals('pending', $record->string_status); + $this->assertEquals(1, $record->integer_status); + } +} diff --git a/tests/Integration/Database/RefreshCommandTest.php b/tests/Integration/Database/RefreshCommandTest.php index a72b67fc1827585bf61c8d9ff0b82f6abbba8fd3..fbce95ca499ec28539f734ea1d1ceacc5b303881 100644 --- a/tests/Integration/Database/RefreshCommandTest.php +++ b/tests/Integration/Database/RefreshCommandTest.php @@ -3,8 +3,9 @@ namespace Illuminate\Tests\Integration\Database; use Illuminate\Support\Facades\DB; +use Orchestra\Testbench\TestCase; -class RefreshCommandTest extends DatabaseTestCase +class RefreshCommandTest extends TestCase { public function testRefreshWithoutRealpath() { @@ -14,7 +15,7 @@ class RefreshCommandTest extends DatabaseTestCase '--path' => 'stubs/', ]; - $this->migrate_refresh_with($options); + $this->migrateRefreshWith($options); } public function testRefreshWithRealpath() @@ -24,11 +25,15 @@ class RefreshCommandTest extends DatabaseTestCase '--realpath' => true, ]; - $this->migrate_refresh_with($options); + $this->migrateRefreshWith($options); } - private function migrate_refresh_with(array $options) + private function migrateRefreshWith(array $options) { + if ($this->app['config']->get('database.default') !== 'testing') { + $this->artisan('db:wipe', ['--drop-views' => true]); + } + $this->beforeApplicationDestroyed(function () use ($options) { $this->artisan('migrate:rollback', $options); }); diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index 8b18d7b17d44f9de3279907baca965e200f4ce72..b3de68a093067788a2321103bc2559e454b97754 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -1,48 +1,55 @@ <?php -namespace Illuminate\Tests\Integration\Database\SchemaTest; +namespace Illuminate\Tests\Integration\Database; use Doctrine\DBAL\Types\Type; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Grammars\SQLiteGrammar; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\DatabaseTestCase; use Illuminate\Tests\Integration\Database\Fixtures\TinyInteger; -/** - * @group integration - */ class SchemaBuilderTest extends DatabaseTestCase { + protected function destroyDatabaseMigrations() + { + Schema::dropAllViews(); + } + public function testDropAllTables() { + $this->expectNotToPerformAssertions(); + Schema::create('table', function (Blueprint $table) { $table->increments('id'); }); Schema::dropAllTables(); + $this->artisan('migrate:install'); + Schema::create('table', function (Blueprint $table) { $table->increments('id'); }); - - $this->assertTrue(true); } public function testDropAllViews() { - DB::statement('create view "view"("id") as select 1'); + $this->expectNotToPerformAssertions(); - Schema::dropAllViews(); + DB::statement('create view foo (id) as select 1'); - DB::statement('create view "view"("id") as select 1'); + Schema::dropAllViews(); - $this->assertTrue(true); + DB::statement('create view foo (id) as select 1'); } public function testRegisterCustomDoctrineType() { + if ($this->driver !== 'sqlite') { + $this->markTestSkipped('Test requires a SQLite connection.'); + } + Schema::registerCustomDoctrineType(TinyInteger::class, TinyInteger::NAME, 'TINYINT'); Schema::create('test', function (Blueprint $table) { @@ -53,20 +60,31 @@ class SchemaBuilderTest extends DatabaseTestCase $table->tinyInteger('test_column')->change(); }); - $expected = [ - 'CREATE TEMPORARY TABLE __temp__test AS SELECT test_column FROM test', - 'DROP TABLE test', - 'CREATE TABLE test (test_column TINYINT NOT NULL)', - 'INSERT INTO test (test_column) SELECT test_column FROM __temp__test', - 'DROP TABLE __temp__test', - ]; + $blueprint->build($this->getConnection(), new SQLiteGrammar); - $statements = $blueprint->toSql($this->getConnection(), new SQLiteGrammar()); + $this->assertArrayHasKey(TinyInteger::NAME, Type::getTypesMap()); + $this->assertSame('tinyinteger', Schema::getColumnType('test', 'test_column')); + } + + public function testRegisterCustomDoctrineTypeASecondTime() + { + if ($this->driver !== 'sqlite') { + $this->markTestSkipped('Test requires a SQLite connection.'); + } + + Schema::registerCustomDoctrineType(TinyInteger::class, TinyInteger::NAME, 'TINYINT'); + + Schema::create('test', function (Blueprint $table) { + $table->string('test_column'); + }); + + $blueprint = new Blueprint('test', function (Blueprint $table) { + $table->tinyInteger('test_column')->change(); + }); - $blueprint->build($this->getConnection(), new SQLiteGrammar()); + $blueprint->build($this->getConnection(), new SQLiteGrammar); $this->assertArrayHasKey(TinyInteger::NAME, Type::getTypesMap()); $this->assertSame('tinyinteger', Schema::getColumnType('test', 'test_column')); - $this->assertEquals($expected, $statements); } } diff --git a/tests/Integration/Database/EloquentModelConnectionsTest.php b/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php similarity index 93% rename from tests/Integration/Database/EloquentModelConnectionsTest.php rename to tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php index 1864ca45d40adf00d3ecfac3676f57008afbc04f..ba296463329ae5f381a051d58d8c88ee79ad9f9a 100644 --- a/tests/Integration/Database/EloquentModelConnectionsTest.php +++ b/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Integration\Database; +namespace Illuminate\Tests\Integration\Database\Sqlite; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; @@ -8,14 +8,13 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class EloquentModelConnectionsTest extends TestCase { protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); + if (getenv('DB_CONNECTION') !== 'testing') { + $this->markTestSkipped('Test requires a Sqlite connection.'); + } $app['config']->set('database.default', 'conn1'); @@ -32,10 +31,8 @@ class EloquentModelConnectionsTest extends TestCase ]); } - protected function setUp(): void + protected function defineDatabaseMigrations() { - parent::setUp(); - Schema::create('parent', function (Blueprint $table) { $table->increments('id'); $table->string('name'); @@ -102,7 +99,7 @@ class ParentModel extends Model { public $table = 'parent'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function children() { @@ -119,7 +116,7 @@ class ChildModel extends Model { public $table = 'child'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function parent() { @@ -132,7 +129,7 @@ class ChildModelDefaultConn2 extends Model public $connection = 'conn2'; public $table = 'child'; public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; public function parent() { diff --git a/tests/Integration/Events/EventFakeTest.php b/tests/Integration/Events/EventFakeTest.php index b69b86e8c88e496eea28d54a8cf7e4e21f613235..5964f1b1e60416a6ec49d26ecc3bba33ebd07c4e 100644 --- a/tests/Integration/Events/EventFakeTest.php +++ b/tests/Integration/Events/EventFakeTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Events; +use Closure; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Event; @@ -10,31 +11,6 @@ use Orchestra\Testbench\TestCase; class EventFakeTest extends TestCase { - /** - * Define environment setup. - * - * @param \Illuminate\Contracts\Foundation\Application $app - * @return void - */ - protected function getEnvironmentSetUp($app) - { - $app['config']->set('app.debug', 'true'); - - // Database configuration - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - /** - * Setup the test environment. - * - * @return void - */ protected function setUp(): void { parent::setUp(); @@ -47,11 +23,6 @@ class EventFakeTest extends TestCase }); } - /** - * Clean up the testing environment before the next test. - * - * @return void - */ protected function tearDown(): void { Schema::dropIfExists('posts'); @@ -126,6 +97,49 @@ class EventFakeTest extends TestCase Event::assertNotDispatched(NonImportantEvent::class); } + + public function testFakeExceptAllowsGivenEventToBeDispatched() + { + Event::fakeExcept(NonImportantEvent::class); + + Event::dispatch(NonImportantEvent::class); + + Event::assertNotDispatched(NonImportantEvent::class); + } + + public function testFakeExceptAllowsGivenEventsToBeDispatched() + { + Event::fakeExcept([ + NonImportantEvent::class, + 'non-fake-event', + ]); + + Event::dispatch(NonImportantEvent::class); + Event::dispatch('non-fake-event'); + + Event::assertNotDispatched(NonImportantEvent::class); + Event::assertNotDispatched('non-fake-event'); + } + + public function testAssertListening() + { + Event::fake(); + Event::listen('event', 'listener'); + Event::listen('event', PostEventSubscriber::class); + Event::listen('event', 'Illuminate\\Tests\\Integration\\Events\\PostAutoEventSubscriber@handle'); + Event::listen('event', [PostEventSubscriber::class, 'foo']); + Event::subscribe(PostEventSubscriber::class); + Event::listen(function (NonImportantEvent $event) { + // do something + }); + + Event::assertListening('event', 'listener'); + Event::assertListening('event', PostEventSubscriber::class); + Event::assertListening('event', PostAutoEventSubscriber::class); + Event::assertListening('event', [PostEventSubscriber::class, 'foo']); + Event::assertListening('post-created', [PostEventSubscriber::class, 'handlePostCreated']); + Event::assertListening(NonImportantEvent::class, Closure::class); + } } class Post extends Model @@ -138,6 +152,29 @@ class NonImportantEvent // } +class PostEventSubscriber +{ + public function handlePostCreated($event) + { + } + + public function subscribe($events) + { + $events->listen( + 'post-created', + [PostEventSubscriber::class, 'handlePostCreated'] + ); + } +} + +class PostAutoEventSubscriber +{ + public function handle($event) + { + // + } +} + class PostObserver { public function saving(Post $post) diff --git a/tests/Integration/Events/ListenerTest.php b/tests/Integration/Events/ListenerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..76cdcb55c691694f438e16a279d9f9c2171a539e --- /dev/null +++ b/tests/Integration/Events/ListenerTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Illuminate\Tests\Integration\Events; + +use Illuminate\Database\DatabaseTransactionsManager; +use Illuminate\Support\Facades\Event; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class ListenerTest extends TestCase +{ + protected function tearDown(): void + { + ListenerTestListener::$ran = false; + ListenerTestListenerAfterCommit::$ran = false; + + m::close(); + } + + public function testClassListenerRunsNormallyIfNoTransactions() + { + $this->app->singleton('db.transactions', function () { + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldNotReceive('addCallback')->once()->andReturn(null); + + return $transactionManager; + }); + + Event::listen(ListenerTestEvent::class, ListenerTestListener::class); + + Event::dispatch(new ListenerTestEvent); + + $this->assertTrue(ListenerTestListener::$ran); + } + + public function testClassListenerDoesntRunInsideTransaction() + { + $this->app->singleton('db.transactions', function () { + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); + + return $transactionManager; + }); + + Event::listen(ListenerTestEvent::class, ListenerTestListenerAfterCommit::class); + + Event::dispatch(new ListenerTestEvent); + + $this->assertFalse(ListenerTestListenerAfterCommit::$ran); + } +} + +class ListenerTestEvent +{ + // +} + +class ListenerTestListener +{ + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} + +class ListenerTestListenerAfterCommit +{ + public static $ran = false; + + public $afterCommit = true; + + public function handle() + { + static::$ran = true; + } +} diff --git a/tests/Integration/Events/QueuedClosureListenerTest.php b/tests/Integration/Events/QueuedClosureListenerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..790f364afd97b4eb944f8537902af2eb6d575d6c --- /dev/null +++ b/tests/Integration/Events/QueuedClosureListenerTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Tests\Integration\Events; + +use Illuminate\Events\CallQueuedListener; +use Illuminate\Events\InvokeQueuedClosure; +use function Illuminate\Events\queueable; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Event; +use Orchestra\Testbench\TestCase; + +class QueuedClosureListenerTest extends TestCase +{ + public function testAnonymousQueuedListenerIsQueued() + { + Bus::fake(); + + Event::listen(queueable(function (TestEvent $event) { + // + })->catch(function (TestEvent $event) { + // + })->onConnection(null)->onQueue(null)); + + Event::dispatch(new TestEvent); + + Bus::assertDispatched(CallQueuedListener::class, function ($job) { + return $job->class == InvokeQueuedClosure::class; + }); + } +} + +class TestEvent +{ + // +} diff --git a/tests/Integration/Filesystem/FilesystemTest.php b/tests/Integration/Filesystem/FilesystemTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a3988951cbd62aae8700eb5b43076f4d0117067a --- /dev/null +++ b/tests/Integration/Filesystem/FilesystemTest.php @@ -0,0 +1,73 @@ +<?php + +namespace Illuminate\Tests\Integration\Filesystem; + +use Illuminate\Support\Facades\File; +use Orchestra\Testbench\TestCase; +use Symfony\Component\Process\Process; + +/** + * @requires OS Linux|Darwin + */ +class FilesystemTest extends TestCase +{ + protected $stubFile; + + protected function setUp(): void + { + $this->afterApplicationCreated(function () { + File::put($file = storage_path('app/public/StardewTaylor.png'), File::get(__DIR__.'/Fixtures/StardewTaylor.png')); + $this->stubFile = $file; + }); + + $this->beforeApplicationDestroyed(function () { + if (File::exists($this->stubFile)) { + File::delete($this->stubFile); + } + }); + + parent::setUp(); + } + + public function testItCanDeleteViaFilesystemShouldUpdatesFileExists() + { + $this->assertTrue(File::exists($this->stubFile)); + $this->assertTrue(File::isFile($this->stubFile)); + + File::delete($this->stubFile); + + $this->assertFalse(File::exists($this->stubFile)); + } + + public function testItCanDeleteViaFilesystemRequiresManualClearStatCacheOnFileExistsFromDifferentProcess() + { + $this->assertTrue(File::exists($this->stubFile)); + $this->assertTrue(File::isFile($this->stubFile)); + + Process::fromShellCommandline("rm {$this->stubFile}")->run(); + + clearstatcache(true, $this->stubFile); + $this->assertFalse(File::exists($this->stubFile)); + } + + public function testItCanDeleteViaFilesystemShouldUpdatesIsFile() + { + $this->assertTrue(File::exists($this->stubFile)); + $this->assertTrue(File::isFile($this->stubFile)); + + File::delete($this->stubFile); + + $this->assertFalse(File::isFile($this->stubFile)); + } + + public function testItCanDeleteViaFilesystemRequiresManualClearStatCacheOnIsFileFromDifferentProcess() + { + $this->assertTrue(File::exists($this->stubFile)); + $this->assertTrue(File::isFile($this->stubFile)); + + Process::fromShellCommandline("rm {$this->stubFile}")->run(); + + clearstatcache(true, $this->stubFile); + $this->assertFalse(File::isFile($this->stubFile)); + } +} diff --git a/tests/Integration/Filesystem/Fixtures/StardewTaylor.png b/tests/Integration/Filesystem/Fixtures/StardewTaylor.png new file mode 100755 index 0000000000000000000000000000000000000000..7c6d717a59d30e10fcb18c638f0e0849bec17b71 Binary files /dev/null and b/tests/Integration/Filesystem/Fixtures/StardewTaylor.png differ diff --git a/tests/Integration/Filesystem/StorageTest.php b/tests/Integration/Filesystem/StorageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6be23dd6ef283da70ded24091e100fbebe2799e1 --- /dev/null +++ b/tests/Integration/Filesystem/StorageTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Illuminate\Tests\Integration\Filesystem; + +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; +use Orchestra\Testbench\TestCase; +use Symfony\Component\Process\Process; + +/** + * @requires OS Linux|Darwin + */ +class StorageTest extends TestCase +{ + protected $stubFile; + + protected function setUp(): void + { + $this->afterApplicationCreated(function () { + File::put($file = storage_path('app/public/StardewTaylor.png'), File::get(__DIR__.'/Fixtures/StardewTaylor.png')); + $this->stubFile = $file; + }); + + $this->beforeApplicationDestroyed(function () { + if (File::exists($this->stubFile)) { + File::delete($this->stubFile); + } + }); + + parent::setUp(); + } + + public function testItCanDeleteViaStorage() + { + Storage::disk('public')->assertExists('StardewTaylor.png'); + $this->assertTrue(Storage::disk('public')->exists('StardewTaylor.png')); + + Storage::disk('public')->delete('StardewTaylor.png'); + + Storage::disk('public')->assertMissing('StardewTaylor.png'); + $this->assertFalse(Storage::disk('public')->exists('StardewTaylor.png')); + } + + public function testItCanDeleteViaFilesystemShouldUpdatesStorage() + { + Storage::disk('public')->assertExists('StardewTaylor.png'); + $this->assertTrue(Storage::disk('public')->exists('StardewTaylor.png')); + + File::delete($this->stubFile); + + Storage::disk('public')->assertMissing('StardewTaylor.png'); + $this->assertFalse(Storage::disk('public')->exists('StardewTaylor.png')); + } + + public function testItCanDeleteViaFilesystemRequiresManualClearStatCacheOnStorageFromDifferentProcess() + { + Storage::disk('public')->assertExists('StardewTaylor.png'); + $this->assertTrue(Storage::disk('public')->exists('StardewTaylor.png')); + + Process::fromShellCommandline("rm {$this->stubFile}")->run(); + + clearstatcache(true, $this->stubFile); + Storage::disk('public')->assertMissing('StardewTaylor.png'); + $this->assertFalse(Storage::disk('public')->exists('StardewTaylor.png')); + } +} diff --git a/tests/Integration/Foundation/DiscoverEventsTest.php b/tests/Integration/Foundation/DiscoverEventsTest.php index 7194d19640ba309a2a2bd37a46b4e39100c8c40a..75b7cc87118f7efe2568758b7cf835d5a37fc778 100644 --- a/tests/Integration/Foundation/DiscoverEventsTest.php +++ b/tests/Integration/Foundation/DiscoverEventsTest.php @@ -8,6 +8,7 @@ use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Events\Event use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Listeners\AbstractListener; use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Listeners\Listener; use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Listeners\ListenerInterface; +use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\UnionListeners\UnionListener; use Orchestra\Testbench\TestCase; class DiscoverEventsTest extends TestCase @@ -30,4 +31,23 @@ class DiscoverEventsTest extends TestCase ], ], $events); } + + /** + * @requires PHP >= 8 + */ + public function testUnionEventsCanBeDiscovered() + { + class_alias(UnionListener::class, 'Tests\Integration\Foundation\Fixtures\EventDiscovery\UnionListeners\UnionListener'); + + $events = DiscoverEvents::within(__DIR__.'/Fixtures/EventDiscovery/UnionListeners', getcwd()); + + $this->assertEquals([ + EventOne::class => [ + UnionListener::class.'@handle', + ], + EventTwo::class => [ + UnionListener::class.'@handle', + ], + ], $events); + } } diff --git a/tests/Integration/Foundation/Fixtures/EventDiscovery/UnionListeners/UnionListener.php b/tests/Integration/Foundation/Fixtures/EventDiscovery/UnionListeners/UnionListener.php new file mode 100644 index 0000000000000000000000000000000000000000..6911e9e1f71b4440bf76bb7c0100e37ad23723d1 --- /dev/null +++ b/tests/Integration/Foundation/Fixtures/EventDiscovery/UnionListeners/UnionListener.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\UnionListeners; + +use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Events\EventOne; +use Illuminate\Tests\Integration\Foundation\Fixtures\EventDiscovery\Events\EventTwo; + +class UnionListener +{ + public function handle(EventOne|EventTwo $event) + { + // + } +} diff --git a/tests/Integration/Foundation/FoundationHelpersTest.php b/tests/Integration/Foundation/FoundationHelpersTest.php index 3aba586e31e84bbea3ccd85d987f37da22f69483..336273fb1a619c5783741ba40283422f26f2dc11 100644 --- a/tests/Integration/Foundation/FoundationHelpersTest.php +++ b/tests/Integration/Foundation/FoundationHelpersTest.php @@ -8,37 +8,47 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class FoundationHelpersTest extends TestCase { public function testRescue() { - $this->assertEquals(rescue(function () { - throw new Exception; - }, 'rescued!'), 'rescued!'); - - $this->assertEquals(rescue(function () { - throw new Exception; - }, function () { - return 'rescued!'; - }), 'rescued!'); - - $this->assertEquals(rescue(function () { - return 'no need to rescue'; - }, 'rescued!'), 'no need to rescue'); - - $testClass = new class { + $this->assertEquals( + 'rescued!', + rescue(function () { + throw new Exception; + }, 'rescued!') + ); + + $this->assertEquals( + 'rescued!', + rescue(function () { + throw new Exception; + }, function () { + return 'rescued!'; + }) + ); + + $this->assertEquals( + 'no need to rescue', + rescue(function () { + return 'no need to rescue'; + }, 'rescued!') + ); + + $testClass = new class + { public function test(int $a) { return $a; } }; - $this->assertEquals(rescue(function () use ($testClass) { - $testClass->test([]); - }, 'rescued!'), 'rescued!'); + $this->assertEquals( + 'rescued!', + rescue(function () use ($testClass) { + $testClass->test([]); + }, 'rescued!') + ); } public function testMixReportsExceptionWhenAssetIsMissingFromManifest() @@ -58,6 +68,7 @@ class FoundationHelpersTest extends TestCase public function testMixSilentlyFailsWhenAssetIsMissingFromManifestWhenNotInDebugMode() { $this->app['config']->set('app.debug', false); + $manifest = $this->makeManifest(); $path = mix('missing.js'); @@ -73,6 +84,7 @@ class FoundationHelpersTest extends TestCase $this->expectExceptionMessage('Unable to locate Mix file: /missing.js.'); $this->app['config']->set('app.debug', true); + $manifest = $this->makeManifest(); try { @@ -89,7 +101,9 @@ class FoundationHelpersTest extends TestCase $handler = new FakeHandler; $this->app->instance(ExceptionHandler::class, $handler); $this->app['config']->set('app.debug', true); + $manifest = $this->makeManifest(); + Route::get('test-route', function () { mix('missing.js'); }); diff --git a/tests/Integration/Foundation/FoundationServiceProvidersTest.php b/tests/Integration/Foundation/FoundationServiceProvidersTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bfc39cd44b3cc5148f3e084ab841c025c174bcd3 --- /dev/null +++ b/tests/Integration/Foundation/FoundationServiceProvidersTest.php @@ -0,0 +1,47 @@ +<?php + +namespace Illuminate\Tests\Integration\Foundation; + +use Illuminate\Support\ServiceProvider; +use Orchestra\Testbench\TestCase; + +class FoundationServiceProvidersTest extends TestCase +{ + protected function getPackageProviders($app) + { + return [HeadServiceProvider::class]; + } + + /** @test */ + public function it_can_boot_service_provider_registered_from_another_service_provider() + { + $this->assertTrue($this->app['tail.registered']); + $this->assertTrue($this->app['tail.booted']); + } +} + +class HeadServiceProvider extends ServiceProvider +{ + public function register() + { + // + } + + public function boot() + { + $this->app->register(TailServiceProvider::class); + } +} + +class TailServiceProvider extends ServiceProvider +{ + public function register() + { + $this->app['tail.registered'] = true; + } + + public function boot() + { + $this->app['tail.booted'] = true; + } +} diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fd4a000a6e00430b1916af6f88468034730c3595 --- /dev/null +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -0,0 +1,174 @@ +<?php + +namespace Illuminate\Tests\Integration\Foundation; + +use Illuminate\Foundation\Console\DownCommand; +use Illuminate\Foundation\Console\UpCommand; +use Illuminate\Foundation\Events\MaintenanceModeDisabled; +use Illuminate\Foundation\Events\MaintenanceModeEnabled; +use Illuminate\Foundation\Http\MaintenanceModeBypassCookie; +use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; +use Orchestra\Testbench\TestCase; +use Symfony\Component\HttpFoundation\Cookie; + +class MaintenanceModeTest extends TestCase +{ + protected function tearDown(): void + { + @unlink(storage_path('framework/down')); + } + + public function testBasicMaintenanceModeResponse() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'refresh' => 60, + ])); + + Route::get('/foo', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->get('/foo'); + + $response->assertStatus(503); + $response->assertHeader('Retry-After', '60'); + $response->assertHeader('Refresh', '60'); + } + + public function testMaintenanceModeCanHaveCustomStatus() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'status' => 200, + ])); + + Route::get('/foo', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->get('/foo'); + + $response->assertStatus(200); + $response->assertHeader('Retry-After', '60'); + } + + public function testMaintenanceModeCanHaveCustomTemplate() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'template' => 'Rendered Content', + ])); + + Route::get('/foo', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->get('/foo'); + + $response->assertStatus(503); + $response->assertHeader('Retry-After', '60'); + $this->assertSame('Rendered Content', $response->original); + } + + public function testMaintenanceModeCanRedirectWithBypassCookie() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'secret' => 'foo', + 'template' => 'Rendered Content', + ])); + + Route::get('/foo', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->get('/foo'); + + $response->assertStatus(302); + $response->assertCookie('laravel_maintenance'); + } + + public function testMaintenanceModeCanBeBypassedWithValidCookie() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'secret' => 'foo', + ])); + + $cookie = MaintenanceModeBypassCookie::create('foo'); + + Route::get('/test', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->withUnencryptedCookies([ + 'laravel_maintenance' => $cookie->getValue(), + ])->get('/test'); + + $response->assertStatus(200); + $this->assertSame('Hello World', $response->original); + } + + public function testMaintenanceModeCantBeBypassedWithInvalidCookie() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'secret' => 'foo', + ])); + + $cookie = MaintenanceModeBypassCookie::create('test-key'); + + Route::get('/test', function () { + return 'Hello World'; + })->middleware(PreventRequestsDuringMaintenance::class); + + $response = $this->withUnencryptedCookies([ + 'laravel_maintenance' => $cookie->getValue(), + ])->get('/test'); + + $response->assertStatus(503); + } + + public function testCanCreateBypassCookies() + { + $cookie = MaintenanceModeBypassCookie::create('test-key'); + + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertSame('laravel_maintenance', $cookie->getName()); + + $this->assertTrue(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'test-key')); + $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'wrong-key')); + + Carbon::setTestNow(now()->addMonths(6)); + $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'test-key')); + + Carbon::setTestNow(null); + } + + public function testDispatchEventWhenMaintenanceModeIsEnabled() + { + Event::fake(); + + Event::assertNotDispatched(MaintenanceModeEnabled::class); + $this->artisan(DownCommand::class); + Event::assertDispatched(MaintenanceModeEnabled::class); + } + + public function testDispatchEventWhenMaintenanceModeIsDisabled() + { + file_put_contents(storage_path('framework/down'), json_encode([ + 'retry' => 60, + 'refresh' => 60, + ])); + + Event::fake(); + + Event::assertNotDispatched(MaintenanceModeDisabled::class); + $this->artisan(UpCommand::class); + Event::assertDispatched(MaintenanceModeDisabled::class); + } +} diff --git a/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php b/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php index 8d641ec453bc122df16307801220f95c6d8f3491..01e97f5b0cd4e668789adbea06c5a551bdfe9100 100644 --- a/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php +++ b/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php @@ -16,11 +16,10 @@ class InteractsWithAuthenticationTest extends TestCase { $app['config']->set('auth.providers.users.model', AuthenticationTestUser::class); - $app['config']->set('database.default', 'testbench'); - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', + $app['config']->set('auth.guards.api', [ + 'driver' => 'token', + 'provider' => 'users', + 'hash' => false, ]); } @@ -86,14 +85,14 @@ class AuthenticationTestUser extends Authenticatable /** * The attributes that are mass assignable. * - * @var array + * @var string[] */ - protected $guarded = ['id']; + protected $guarded = []; /** * The attributes that should be hidden for arrays. * - * @var array + * @var string[] */ protected $hidden = [ 'password', 'remember_token', diff --git a/tests/Integration/Http/Fixtures/AnonymousResourceCollectionWithPaginationInformation.php b/tests/Integration/Http/Fixtures/AnonymousResourceCollectionWithPaginationInformation.php new file mode 100644 index 0000000000000000000000000000000000000000..10d77fde3d0fdd2b00b80bc760c45f1271670774 --- /dev/null +++ b/tests/Integration/Http/Fixtures/AnonymousResourceCollectionWithPaginationInformation.php @@ -0,0 +1,20 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; + +class AnonymousResourceCollectionWithPaginationInformation extends AnonymousResourceCollection +{ + public function paginationInformation($request) + { + $paginated = $this->resource->toArray(); + + return [ + 'current_page' => $paginated['current_page'], + 'per_page' => $paginated['per_page'], + 'total' => $paginated['total'], + 'total_page' => $paginated['last_page'], + ]; + } +} diff --git a/tests/Integration/Http/Fixtures/Author.php b/tests/Integration/Http/Fixtures/Author.php index a5029db270be748ed38833ed92aa68381c98562b..802a2dcfd45c77386bba261b75b30f707aa0f57a 100644 --- a/tests/Integration/Http/Fixtures/Author.php +++ b/tests/Integration/Http/Fixtures/Author.php @@ -9,7 +9,7 @@ class Author extends Model /** * The attributes that aren't mass assignable. * - * @var array + * @var string[] */ protected $guarded = []; } diff --git a/tests/Integration/Http/Fixtures/JsonSerializableResource.php b/tests/Integration/Http/Fixtures/JsonSerializableResource.php index 21831139192e8b60f23384029cea7bd0720a5fb6..ce330cb51088f2f3ba30e276527c8962fd397a4c 100644 --- a/tests/Integration/Http/Fixtures/JsonSerializableResource.php +++ b/tests/Integration/Http/Fixtures/JsonSerializableResource.php @@ -13,7 +13,7 @@ class JsonSerializableResource implements JsonSerializable $this->resource = $resource; } - public function jsonSerialize() + public function jsonSerialize(): array { return [ 'id' => $this->resource->id, diff --git a/tests/Integration/Http/Fixtures/Post.php b/tests/Integration/Http/Fixtures/Post.php index c0bd014e4be18db00a3d6aaafd101290db968aa4..2eb1df88986e150c40df96e4454da43f7201ac96 100644 --- a/tests/Integration/Http/Fixtures/Post.php +++ b/tests/Integration/Http/Fixtures/Post.php @@ -9,7 +9,17 @@ class Post extends Model /** * The attributes that aren't mass assignable. * - * @var array + * @var string[] */ protected $guarded = []; + + /** + * Return whether the post is published. + * + * @return bool + */ + public function getIsPublishedAttribute() + { + return true; + } } diff --git a/tests/Integration/Http/Fixtures/PostCollectionResourceWithPaginationInformation.php b/tests/Integration/Http/Fixtures/PostCollectionResourceWithPaginationInformation.php new file mode 100644 index 0000000000000000000000000000000000000000..020be9d3606251cc291c60788ef2bc660ceb9e59 --- /dev/null +++ b/tests/Integration/Http/Fixtures/PostCollectionResourceWithPaginationInformation.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\ResourceCollection; + +class PostCollectionResourceWithPaginationInformation extends ResourceCollection +{ + public $collects = PostResource::class; + + public function toArray($request) + { + return ['data' => $this->collection]; + } + + public function paginationInformation($request) + { + $paginated = $this->resource->toArray(); + + return [ + 'current_page' => $paginated['current_page'], + 'per_page' => $paginated['per_page'], + 'total' => $paginated['total'], + 'total_page' => $paginated['last_page'], + ]; + } +} diff --git a/tests/Integration/Http/Fixtures/PostResourceWithAnonymousResourceCollectionWithPaginationInformation.php b/tests/Integration/Http/Fixtures/PostResourceWithAnonymousResourceCollectionWithPaginationInformation.php new file mode 100644 index 0000000000000000000000000000000000000000..8a8d0a2dab0831c0f95065fa29e0c154786a1e8e --- /dev/null +++ b/tests/Integration/Http/Fixtures/PostResourceWithAnonymousResourceCollectionWithPaginationInformation.php @@ -0,0 +1,28 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\JsonResource; + +class PostResourceWithAnonymousResourceCollectionWithPaginationInformation extends JsonResource +{ + public function toArray($request) + { + return ['id' => $this->id, 'title' => $this->title, 'custom' => true]; + } + + /** + * Create a new anonymous resource collection. + * + * @param mixed $resource + * @return AnonymousResourceCollectionWithPaginationInformation + */ + public static function collection($resource) + { + return tap(new AnonymousResourceCollectionWithPaginationInformation($resource, static::class), function ($collection) { + if (property_exists(static::class, 'preserveKeys')) { + $collection->preserveKeys = (new static([]))->preserveKeys === true; + } + }); + } +} diff --git a/tests/Integration/Http/Fixtures/PostResourceWithJsonOptions.php b/tests/Integration/Http/Fixtures/PostResourceWithJsonOptions.php new file mode 100644 index 0000000000000000000000000000000000000000..d7524b75d8d8a1306bed432116dfecd7e2e086ca --- /dev/null +++ b/tests/Integration/Http/Fixtures/PostResourceWithJsonOptions.php @@ -0,0 +1,22 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\JsonResource; + +class PostResourceWithJsonOptions extends JsonResource +{ + public function toArray($request) + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'reading_time' => $this->reading_time, + ]; + } + + public function jsonOptions() + { + return JSON_PRESERVE_ZERO_FRACTION; + } +} diff --git a/tests/Integration/Http/Fixtures/PostResourceWithJsonOptionsAndTypeHints.php b/tests/Integration/Http/Fixtures/PostResourceWithJsonOptionsAndTypeHints.php new file mode 100644 index 0000000000000000000000000000000000000000..834af06c8bf24d55d4858ff2060470eeca7f06c7 --- /dev/null +++ b/tests/Integration/Http/Fixtures/PostResourceWithJsonOptionsAndTypeHints.php @@ -0,0 +1,27 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\JsonResource; + +class PostResourceWithJsonOptionsAndTypeHints extends JsonResource +{ + public function __construct(Post $resource) + { + parent::__construct($resource); + } + + public function toArray($request) + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'reading_time' => $this->reading_time, + ]; + } + + public function jsonOptions() + { + return JSON_PRESERVE_ZERO_FRACTION; + } +} diff --git a/tests/Integration/Http/Fixtures/PostResourceWithOptionalAppendedAttributes.php b/tests/Integration/Http/Fixtures/PostResourceWithOptionalAppendedAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..e961e7dc855cc151389628539accd24598ec9c1d --- /dev/null +++ b/tests/Integration/Http/Fixtures/PostResourceWithOptionalAppendedAttributes.php @@ -0,0 +1,24 @@ +<?php + +namespace Illuminate\Tests\Integration\Http\Fixtures; + +use Illuminate\Http\Resources\Json\JsonResource; + +class PostResourceWithOptionalAppendedAttributes extends JsonResource +{ + public function toArray($request) + { + return [ + 'id' => $this->id, + 'first' => $this->whenAppended('is_published'), + 'second' => $this->whenAppended('is_published', 'override value'), + 'third' => $this->whenAppended('is_published', function () { + return 'override value'; + }), + 'fourth' => $this->whenAppended('is_published', $this->is_published, 'default'), + 'fifth' => $this->whenAppended('is_published', $this->is_published, function () { + return 'default'; + }), + ]; + } +} diff --git a/tests/Integration/Http/JsonResponseTest.php b/tests/Integration/Http/JsonResponseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..adb764c237afa9b3ad681745d1ad967f450c5131 --- /dev/null +++ b/tests/Integration/Http/JsonResponseTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Illuminate\Tests\Integration\Http; + +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Route; +use Orchestra\Testbench\TestCase; + +class JsonResponseTest extends TestCase +{ + public function testResponseWithInvalidJsonThrowsException() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Malformed UTF-8 characters, possibly incorrectly encoded'); + + Route::get('/response', function () { + return new JsonResponse(new class implements \JsonSerializable + { + public function jsonSerialize(): string + { + return "\xB1\x31"; + } + }); + }); + + $this->withoutExceptionHandling(); + + $this->get('/response'); + } + + public function testResponseSetDataPassesWithPriorJsonErrors() + { + $response = new JsonResponse(); + + // Trigger json_last_error() to have a non-zero value... + json_encode(['a' => acos(2)]); + + $response->setData(new class implements Jsonable + { + public function toJson($options = 0): string + { + return '{}'; + } + }); + + $this->assertJson($response->getContent()); + } +} diff --git a/tests/Integration/Http/ResourceTest.php b/tests/Integration/Http/ResourceTest.php index c9332ac63d5fa098701bc17677592f04d19366b6..94eff88393830dbe0baf059b059f50822f4e554d 100644 --- a/tests/Integration/Http/ResourceTest.php +++ b/tests/Integration/Http/ResourceTest.php @@ -2,10 +2,15 @@ namespace Illuminate\Tests\Integration\Http; +use Illuminate\Foundation\Http\Middleware\ValidatePostSize; +use Illuminate\Http\Exceptions\PostTooLargeException; +use Illuminate\Http\Request; use Illuminate\Http\Resources\ConditionallyLoadsAttributes; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MergeValue; use Illuminate\Http\Resources\MissingValue; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Route; @@ -15,8 +20,13 @@ use Illuminate\Tests\Integration\Http\Fixtures\EmptyPostCollectionResource; use Illuminate\Tests\Integration\Http\Fixtures\ObjectResource; use Illuminate\Tests\Integration\Http\Fixtures\Post; use Illuminate\Tests\Integration\Http\Fixtures\PostCollectionResource; +use Illuminate\Tests\Integration\Http\Fixtures\PostCollectionResourceWithPaginationInformation; use Illuminate\Tests\Integration\Http\Fixtures\PostResource; +use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithAnonymousResourceCollectionWithPaginationInformation; use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithExtraData; +use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithJsonOptions; +use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithJsonOptionsAndTypeHints; +use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithOptionalAppendedAttributes; use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithOptionalData; use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithOptionalMerging; use Illuminate\Tests\Integration\Http\Fixtures\PostResourceWithOptionalPivotRelationship; @@ -26,11 +36,9 @@ use Illuminate\Tests\Integration\Http\Fixtures\ReallyEmptyPostResource; use Illuminate\Tests\Integration\Http\Fixtures\ResourceWithPreservedKeys; use Illuminate\Tests\Integration\Http\Fixtures\SerializablePostResource; use Illuminate\Tests\Integration\Http\Fixtures\Subscription; +use Mockery; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class ResourceTest extends TestCase { public function testResourcesMayBeConvertedToJson() @@ -39,6 +47,7 @@ class ResourceTest extends TestCase return new PostResource(new Post([ 'id' => 5, 'title' => 'Test Title', + 'abstract' => 'Test abstract', ])); }); @@ -56,6 +65,17 @@ class ResourceTest extends TestCase ]); } + public function testResourcesMayBeConvertedToJsonWithToJsonMethod() + { + $resource = new PostResource(new Post([ + 'id' => 5, + 'title' => 'Test Title', + 'abstract' => 'Test abstract', + ])); + + $this->assertSame('{"id":5,"title":"Test Title","custom":true}', $resource->toJson()); + } + public function testAnObjectsMayBeConvertedToJson() { Route::get('/', function () { @@ -141,6 +161,59 @@ class ResourceTest extends TestCase ]); } + public function testResourcesMayHaveOptionalAppendedAttributes() + { + Route::get('/', function () { + $post = new Post([ + 'id' => 5, + ]); + + $post->append('is_published'); + + return new PostResourceWithOptionalAppendedAttributes($post); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + 'id' => 5, + 'first' => true, + 'second' => 'override value', + 'third' => 'override value', + 'fourth' => true, + 'fifth' => true, + ], + ]); + } + + public function testResourcesWithOptionalAppendedAttributesReturnDefaultValuesAndNotMissingValues() + { + Route::get('/', function () { + return new PostResourceWithOptionalAppendedAttributes(new Post([ + 'id' => 5, + ])); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertExactJson([ + 'data' => [ + 'id' => 5, + 'fourth' => 'default', + 'fifth' => 'default', + ], + ]); + } + public function testResourcesMayHaveOptionalMerges() { Route::get('/', function () { @@ -424,6 +497,85 @@ class ResourceTest extends TestCase ]); } + public function testResourcesMayCustomizeJsonOptions() + { + Route::get('/', function () { + return new PostResourceWithJsonOptions(new Post([ + 'id' => 5, + 'title' => 'Test Title', + 'reading_time' => 3.0, + ])); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $this->assertEquals( + '{"data":{"id":5,"title":"Test Title","reading_time":3.0}}', + $response->baseResponse->content() + ); + } + + public function testCollectionResourcesMayCustomizeJsonOptions() + { + Route::get('/', function () { + return PostResourceWithJsonOptions::collection(collect([ + new Post(['id' => 5, 'title' => 'Test Title', 'reading_time' => 3.0]), + ])); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $this->assertEquals( + '{"data":[{"id":5,"title":"Test Title","reading_time":3.0}]}', + $response->baseResponse->content() + ); + } + + public function testResourcesMayCustomizeJsonOptionsOnPaginatedResponse() + { + Route::get('/', function () { + $paginator = new LengthAwarePaginator( + collect([new Post(['id' => 5, 'title' => 'Test Title', 'reading_time' => 3.0])]), + 10, 15, 1 + ); + + return PostResourceWithJsonOptions::collection($paginator); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $this->assertEquals( + '{"data":[{"id":5,"title":"Test Title","reading_time":3.0}],"links":{"first":"\/?page=1","last":"\/?page=1","prev":null,"next":null},"meta":{"current_page":1,"from":1,"last_page":1,"links":[{"url":null,"label":"« Previous","active":false},{"url":"\/?page=1","label":"1","active":true},{"url":null,"label":"Next »","active":false}],"path":"\/","per_page":15,"to":1,"total":10}}', + $response->baseResponse->content() + ); + } + + public function testResourcesMayCustomizeJsonOptionsWithTypeHintedConstructor() + { + Route::get('/', function () { + return new PostResourceWithJsonOptionsAndTypeHints(new Post([ + 'id' => 5, + 'title' => 'Test Title', + 'reading_time' => 3.0, + ])); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $this->assertEquals( + '{"data":{"id":5,"title":"Test Title","reading_time":3.0}}', + $response->baseResponse->content() + ); + } + public function testCustomHeadersMayBeSetOnResponses() { Route::get('/', function () { @@ -612,6 +764,117 @@ class ResourceTest extends TestCase ]); } + public function testCursorPaginatorReceiveLinks() + { + Route::get('/', function () { + $paginator = new CursorPaginator( + collect([new Post(['id' => 5, 'title' => 'Test Title']), new Post(['id' => 6, 'title' => 'Hello'])]), + 1, null, ['parameters' => ['id']] + ); + + return new PostCollectionResource($paginator); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'id' => 5, + 'title' => 'Test Title', + ], + ], + 'links' => [ + 'first' => null, + 'last' => null, + 'prev' => null, + 'next' => '/?cursor='.(new Cursor(['id' => 5]))->encode(), + ], + 'meta' => [ + 'path' => '/', + 'per_page' => 1, + ], + ]); + } + + public function testCursorPaginatorResourceCanPreserveQueryParameters() + { + Route::get('/', function () { + $collection = collect([new Post(['id' => 5, 'title' => 'Test Title']), new Post(['id' => 6, 'title' => 'Hello'])]); + $paginator = new CursorPaginator( + $collection, 1, null, ['parameters' => ['id']] + ); + + return PostCollectionResource::make($paginator)->preserveQuery(); + }); + + $response = $this->withoutExceptionHandling()->get( + '/?framework=laravel&author=Otwell', ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'id' => 5, + 'title' => 'Test Title', + ], + ], + 'links' => [ + 'first' => null, + 'last' => null, + 'prev' => null, + 'next' => '/?framework=laravel&author=Otwell&cursor='.(new Cursor(['id' => 5]))->encode(), + ], + 'meta' => [ + 'path' => '/', + 'per_page' => 1, + ], + ]); + } + + public function testCursorPaginatorResourceCanReceiveQueryParameters() + { + Route::get('/', function () { + $collection = collect([new Post(['id' => 5, 'title' => 'Test Title']), new Post(['id' => 6, 'title' => 'Hello'])]); + $paginator = new CursorPaginator( + $collection, 1, null, ['parameters' => ['id']] + ); + + return PostCollectionResource::make($paginator)->withQuery(['author' => 'Taylor']); + }); + + $response = $this->withoutExceptionHandling()->get( + '/?framework=laravel&author=Otwell', ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'id' => 5, + 'title' => 'Test Title', + ], + ], + 'links' => [ + 'first' => null, + 'last' => null, + 'prev' => null, + 'next' => '/?author=Taylor&cursor='.(new Cursor(['id' => 5]))->encode(), + ], + 'meta' => [ + 'path' => '/', + 'per_page' => 1, + ], + ]); + } + public function testToJsonMayBeLeftOffOfCollection() { Route::get('/', function () { @@ -705,6 +968,68 @@ class ResourceTest extends TestCase }); } + public function testCollectionResourceWithPaginationInfomation() + { + $posts = collect([ + new Post(['id' => 5, 'title' => 'Test Title']), + ]); + + Route::get('/', function () use ($posts) { + return new PostCollectionResourceWithPaginationInformation(new LengthAwarePaginator($posts, 10, 1, 1)); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', + ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'id' => 5, + 'title' => 'Test Title', + ], + ], + 'current_page' => 1, + 'per_page' => 1, + 'total_page' => 10, + 'total' => 10, + ]); + } + + public function testResourceWithPaginationInfomation() + { + $posts = collect([ + new Post(['id' => 5, 'title' => 'Test Title']), + ]); + + Route::get('/', function () use ($posts) { + return PostResourceWithAnonymousResourceCollectionWithPaginationInformation::collection(new LengthAwarePaginator($posts, 10, 1, 1)); + }); + + $response = $this->withoutExceptionHandling()->get( + '/', + ['Accept' => 'application/json'] + ); + + $response->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'id' => 5, + 'title' => 'Test Title', + ], + ], + 'current_page' => 1, + 'per_page' => 1, + 'total_page' => 10, + 'total' => 10, + ]); + } + public function testCollectionResourcesAreCountable() { $posts = collect([ @@ -715,7 +1040,7 @@ class ResourceTest extends TestCase $collection = new PostCollectionResource($posts); $this->assertCount(2, $collection); - $this->assertSame(2, count($collection)); + $this->assertCount(2, $collection); } public function testKeysArePreservedIfTheResourceIsFlaggedToPreserveKeys() @@ -791,7 +1116,8 @@ class ResourceTest extends TestCase public function testLeadingMergeKeyedValueIsMergedCorrectly() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -809,9 +1135,20 @@ class ResourceTest extends TestCase ], $results); } + public function testPostTooLargeException() + { + $this->expectException(PostTooLargeException::class); + + $request = Mockery::mock(Request::class, ['server' => ['CONTENT_LENGTH' => '2147483640']]); + $post = new ValidatePostSize; + $post->handle($request, function () { + }); + } + public function testLeadingMergeKeyedValueIsMergedCorrectlyWhenFirstValueIsMissing() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -835,7 +1172,8 @@ class ResourceTest extends TestCase public function testLeadingMergeValueIsMergedCorrectly() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -860,7 +1198,8 @@ class ResourceTest extends TestCase public function testMergeValuesMayBeMissing() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -885,7 +1224,8 @@ class ResourceTest extends TestCase public function testInitialMergeValuesMayBeMissing() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -910,7 +1250,8 @@ class ResourceTest extends TestCase public function testMergeValueCanMergeJsonSerializable() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -941,7 +1282,8 @@ class ResourceTest extends TestCase public function testMergeValueCanMergeCollectionOfJsonSerializable() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -967,7 +1309,8 @@ class ResourceTest extends TestCase public function testAllMergeValuesMayBeMissing() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() @@ -992,7 +1335,8 @@ class ResourceTest extends TestCase public function testNestedMerges() { - $filter = new class { + $filter = new class + { use ConditionallyLoadsAttributes; public function work() diff --git a/tests/Integration/Http/ResponseTest.php b/tests/Integration/Http/ResponseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..044e64006475c6ff908bdf5be4076d85627591cd --- /dev/null +++ b/tests/Integration/Http/ResponseTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Tests\Integration\Http; + +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Route; +use Orchestra\Testbench\TestCase; + +class ResponseTest extends TestCase +{ + public function testResponseWithInvalidJsonThrowsException() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Malformed UTF-8 characters, possibly incorrectly encoded'); + + Route::get('/response', function () { + return (new Response())->setContent(new class implements \JsonSerializable + { + public function jsonSerialize(): string + { + return "\xB1\x31"; + } + }); + }); + + $this->withoutExceptionHandling(); + + $this->get('/response'); + } +} diff --git a/tests/Integration/Http/ThrottleRequestsTest.php b/tests/Integration/Http/ThrottleRequestsTest.php index 55f055697ee9478786cce85ec06a1de57b7b6406..a23014bd03f02f12e3fe295fc0ebbc03b4546fd9 100644 --- a/tests/Integration/Http/ThrottleRequestsTest.php +++ b/tests/Integration/Http/ThrottleRequestsTest.php @@ -2,6 +2,9 @@ namespace Illuminate\Tests\Integration\Http; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\RateLimiting\GlobalLimit; +use Illuminate\Container\Container; use Illuminate\Http\Exceptions\ThrottleRequestsException; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Support\Carbon; @@ -9,14 +12,12 @@ use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; use Throwable; -/** - * @group integration - */ class ThrottleRequestsTest extends TestCase { protected function tearDown(): void { parent::tearDown(); + Carbon::setTestNow(null); } @@ -56,4 +57,42 @@ class ThrottleRequestsTest extends TestCase $this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']); } } + + public function testLimitingUsingNamedLimiter() + { + $rateLimiter = Container::getInstance()->make(RateLimiter::class); + + $rateLimiter->for('test', function ($request) { + return new GlobalLimit(2, 1); + }); + + Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 0)); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $response = $this->withoutExceptionHandling()->get('/'); + $this->assertSame('yes', $response->getContent()); + $this->assertEquals(2, $response->headers->get('X-RateLimit-Limit')); + $this->assertEquals(1, $response->headers->get('X-RateLimit-Remaining')); + + $response = $this->withoutExceptionHandling()->get('/'); + $this->assertSame('yes', $response->getContent()); + $this->assertEquals(2, $response->headers->get('X-RateLimit-Limit')); + $this->assertEquals(0, $response->headers->get('X-RateLimit-Remaining')); + + Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 58)); + + try { + $this->withoutExceptionHandling()->get('/'); + } catch (Throwable $e) { + $this->assertInstanceOf(ThrottleRequestsException::class, $e); + $this->assertEquals(429, $e->getStatusCode()); + $this->assertEquals(2, $e->getHeaders()['X-RateLimit-Limit']); + $this->assertEquals(0, $e->getHeaders()['X-RateLimit-Remaining']); + $this->assertEquals(2, $e->getHeaders()['Retry-After']); + $this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']); + } + } } diff --git a/tests/Integration/Http/ThrottleRequestsWithRedisTest.php b/tests/Integration/Http/ThrottleRequestsWithRedisTest.php index 34b34d4f65ae54ec4479bc8e42b16af6f5eac8bc..ef7a98b75401f0c06deaf13d575df6bc791b17d4 100644 --- a/tests/Integration/Http/ThrottleRequestsWithRedisTest.php +++ b/tests/Integration/Http/ThrottleRequestsWithRedisTest.php @@ -9,9 +9,6 @@ use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; use Throwable; -/** - * @group integration - */ class ThrottleRequestsWithRedisTest extends TestCase { use InteractsWithRedis; diff --git a/tests/Integration/Log/LoggingIntegrationTest.php b/tests/Integration/Log/LoggingIntegrationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9343f0089f0f4a8be55d64a345fe829b5166deff --- /dev/null +++ b/tests/Integration/Log/LoggingIntegrationTest.php @@ -0,0 +1,16 @@ +<?php + +namespace Illuminate\Tests\Integration\Log; + +use Illuminate\Support\Facades\Log; +use Orchestra\Testbench\TestCase; + +class LoggingIntegrationTest extends TestCase +{ + public function testLoggingCanBeRunWithoutEncounteringExceptions() + { + $this->expectNotToPerformAssertions(); + + Log::info('Hello World'); + } +} diff --git a/tests/Integration/Mail/RenderingMailWithLocaleTest.php b/tests/Integration/Mail/RenderingMailWithLocaleTest.php index e86601f3ea3c325596633cc2cfeba601cd634b67..2780d60b5568175bc6c2b667d994f223baf99e45 100644 --- a/tests/Integration/Mail/RenderingMailWithLocaleTest.php +++ b/tests/Integration/Mail/RenderingMailWithLocaleTest.php @@ -6,9 +6,6 @@ use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\View; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RenderingMailWithLocaleTest extends TestCase { protected function getEnvironmentSetUp($app) diff --git a/tests/Integration/Mail/SendingMailWithLocaleTest.php b/tests/Integration/Mail/SendingMailWithLocaleTest.php index 14499c4a5c9ceb599338c223a645ff6d5370bd40..36e1123c8fb73a4e1f8b3fd0e0839127578ef1d2 100644 --- a/tests/Integration/Mail/SendingMailWithLocaleTest.php +++ b/tests/Integration/Mail/SendingMailWithLocaleTest.php @@ -5,31 +5,18 @@ namespace Illuminate\Tests\Integration\Mail; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\LocaleUpdated; -use Illuminate\Foundation\Testing\Assert; use Illuminate\Mail\Mailable; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\View; -use Mockery as m; +use Illuminate\Testing\Assert; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class SendingMailWithLocaleTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - - m::close(); - } - protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - $app['config']->set('mail.driver', 'array'); $app['config']->set('app.locale', 'en'); @@ -47,17 +34,12 @@ class SendingMailWithLocaleTest extends TestCase ]); } - protected function setUp(): void - { - parent::setUp(); - } - public function testMailIsSentWithDefaultLocale() { Mail::to('test@mail.com')->send(new TestMail); $this->assertStringContainsString('name', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -66,19 +48,19 @@ class SendingMailWithLocaleTest extends TestCase Mail::to('test@mail.com')->locale('ar')->send(new TestMail); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } public function testMailIsSentWithLocaleFromMailable() { - $mailable = new TestMail(); + $mailable = new TestMail; $mailable->locale('ar'); Mail::to('test@mail.com')->send($mailable); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -93,7 +75,7 @@ class SendingMailWithLocaleTest extends TestCase Mail::to('test@mail.com')->locale('es')->send(new TimestampTestMail); Assert::assertMatchesRegularExpression('/nombre (en|dentro de) (un|1) día/', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); $this->assertSame('en', Carbon::getLocale()); @@ -109,7 +91,7 @@ class SendingMailWithLocaleTest extends TestCase Mail::to($recipient)->send(new TestMail); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -123,7 +105,7 @@ class SendingMailWithLocaleTest extends TestCase Mail::to($recipient)->locale('ar')->send(new TestMail); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -142,7 +124,7 @@ class SendingMailWithLocaleTest extends TestCase Mail::to($toRecipient)->cc($ccRecipient)->send(new TestMail); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -162,7 +144,7 @@ class SendingMailWithLocaleTest extends TestCase Mail::to($recipients)->send(new TestMail); $this->assertStringContainsString('name', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -174,11 +156,11 @@ class SendingMailWithLocaleTest extends TestCase $this->assertSame('en', app('translator')->getLocale()); $this->assertStringContainsString('esm', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); $this->assertStringContainsString('name', - app('swift.transport')->messages()[1]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[1]->getBody() ); } } diff --git a/tests/Integration/Mail/SendingQueuedMailTest.php b/tests/Integration/Mail/SendingQueuedMailTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6f5cc503ee95251ac1d4c383879a51f2c3ed441e --- /dev/null +++ b/tests/Integration/Mail/SendingQueuedMailTest.php @@ -0,0 +1,50 @@ +<?php + +namespace Illuminate\Tests\Integration\Mail; + +use Illuminate\Mail\Mailable; +use Illuminate\Mail\SendQueuedMailable; +use Illuminate\Queue\Middleware\RateLimited; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\View; +use Orchestra\Testbench\TestCase; + +class SendingQueuedMailTest extends TestCase +{ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('mail.driver', 'array'); + + View::addLocation(__DIR__.'/Fixtures'); + } + + public function testMailIsSentWithDefaultLocale() + { + Queue::fake(); + + Mail::to('test@mail.com')->queue(new SendingQueuedMailTestMail); + + Queue::assertPushed(SendQueuedMailable::class, function ($job) { + return $job->middleware[0] instanceof RateLimited; + }); + } +} + +class SendingQueuedMailTestMail extends Mailable +{ + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->view('view'); + } + + public function middleware() + { + return [new RateLimited('limiter')]; + } +} diff --git a/tests/Integration/Migration/MigratorTest.php b/tests/Integration/Migration/MigratorTest.php index 50ee6f3cbd6b8a71aa7f7aabf666442f70289cb5..b8c50060b114c8f79f220c2612662e91bd78ad89 100644 --- a/tests/Integration/Migration/MigratorTest.php +++ b/tests/Integration/Migration/MigratorTest.php @@ -2,42 +2,78 @@ namespace Illuminate\Tests\Integration\Migration; +use Illuminate\Support\Facades\DB; +use Mockery; +use Mockery\Mock; use Orchestra\Testbench\TestCase; -use PDOException; +use Symfony\Component\Console\Output\OutputInterface; class MigratorTest extends TestCase { - protected function getEnvironmentSetUp($app) + /** + * @var Mock + */ + private $output; + + protected function setUp(): void { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + parent::setUp(); + + $this->output = Mockery::mock(OutputInterface::class); + $this->subject = $this->app->make('migrator'); + $this->subject->setOutput($this->output); + $this->subject->getRepository()->createRepository(); } - public function testDontDisplayOutputWhenOutputObjectIsNotAvailable() + public function testMigrate() { - $migrator = $this->app->make('migrator'); + $this->expectOutput('<comment>Migrating:</comment> 2014_10_12_000000_create_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Migrated:</info> 2014_10_12_000000_create_people_table (.*)#')); + $this->expectOutput('<comment>Migrating:</comment> 2015_10_04_000000_modify_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Migrated:</info> 2015_10_04_000000_modify_people_table (.*)#')); + $this->expectOutput('<comment>Migrating:</comment> 2016_10_04_000000_modify_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Migrated:</info> 2016_10_04_000000_modify_people_table (.*)#')); + + $this->subject->run([__DIR__.'/fixtures']); - $migrator->getRepository()->createRepository(); + self::assertTrue(DB::getSchemaBuilder()->hasTable('people')); + self::assertTrue(DB::getSchemaBuilder()->hasColumn('people', 'first_name')); + self::assertTrue(DB::getSchemaBuilder()->hasColumn('people', 'last_name')); + } + + public function testRollback() + { + $this->getConnection()->statement('CREATE TABLE people(id INT, first_name VARCHAR, last_name VARCHAR);'); + $this->subject->getRepository()->log('2014_10_12_000000_create_people_table', 1); + $this->subject->getRepository()->log('2015_10_04_000000_modify_people_table', 1); + $this->subject->getRepository()->log('2016_10_04_000000_modify_people_table', 1); - $migrator->run([__DIR__.'/fixtures']); + $this->expectOutput('<comment>Rolling back:</comment> 2016_10_04_000000_modify_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Rolled back:</info> 2016_10_04_000000_modify_people_table (.*)#')); + $this->expectOutput('<comment>Rolling back:</comment> 2015_10_04_000000_modify_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Rolled back:</info> 2015_10_04_000000_modify_people_table (.*)#')); + $this->expectOutput('<comment>Rolling back:</comment> 2014_10_12_000000_create_people_table'); + $this->expectOutput(Mockery::pattern('#<info>Rolled back:</info> 2014_10_12_000000_create_people_table (.*)#')); - $this->assertTrue($this->tableExists('people')); + $this->subject->rollback([__DIR__.'/fixtures']); + + self::assertFalse(DB::getSchemaBuilder()->hasTable('people')); } - private function tableExists($table): bool + public function testPretendMigrate() { - try { - $this->app->make('db')->select("SELECT COUNT(*) FROM $table"); - } catch (PDOException $e) { - return false; - } + $this->expectOutput('<info>CreatePeopleTable:</info> create table "people" ("id" integer not null primary key autoincrement, "name" varchar not null, "email" varchar not null, "password" varchar not null, "remember_token" varchar, "created_at" datetime, "updated_at" datetime)'); + $this->expectOutput('<info>CreatePeopleTable:</info> create unique index "people_email_unique" on "people" ("email")'); + $this->expectOutput('<info>ModifyPeopleTable:</info> alter table "people" add column "first_name" varchar'); + $this->expectOutput('<info>2016_10_04_000000_modify_people_table:</info> alter table "people" add column "last_name" varchar'); + + $this->subject->run([__DIR__.'/fixtures'], ['pretend' => true]); - return true; + self::assertFalse(DB::getSchemaBuilder()->hasTable('people')); + } + + private function expectOutput($argument): void + { + $this->output->shouldReceive('writeln')->once()->with($argument); } } diff --git a/tests/Integration/Migration/fixtures/2015_10_04_000000_modify_people_table.php b/tests/Integration/Migration/fixtures/2015_10_04_000000_modify_people_table.php new file mode 100644 index 0000000000000000000000000000000000000000..c6a82758376103cd2c75d1548486202cd313b43f --- /dev/null +++ b/tests/Integration/Migration/fixtures/2015_10_04_000000_modify_people_table.php @@ -0,0 +1,32 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class ModifyPeopleTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('people', function (Blueprint $table) { + $table->string('first_name')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('people', function (Blueprint $table) { + $table->dropColumn('first_name'); + }); + } +} diff --git a/tests/Integration/Migration/fixtures/2016_10_04_000000_modify_people_table.php b/tests/Integration/Migration/fixtures/2016_10_04_000000_modify_people_table.php new file mode 100644 index 0000000000000000000000000000000000000000..fe69cb04519d5fb6e4cf22291ada8f8ecdec6f68 --- /dev/null +++ b/tests/Integration/Migration/fixtures/2016_10_04_000000_modify_people_table.php @@ -0,0 +1,32 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('people', function (Blueprint $table) { + $table->string('last_name')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('people', function (Blueprint $table) { + $table->dropColumn('last_name'); + }); + } +}; diff --git a/tests/Integration/Notifications/Fixtures/html.blade.php b/tests/Integration/Notifications/Fixtures/html.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..7a63f586fe3aca0abebe9a65f9d51dcdcf60adf1 --- /dev/null +++ b/tests/Integration/Notifications/Fixtures/html.blade.php @@ -0,0 +1 @@ +htmlContent diff --git a/tests/Integration/Notifications/Fixtures/plain.blade.php b/tests/Integration/Notifications/Fixtures/plain.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..93198efc1290886749ba4498c0c104f9f239ff32 --- /dev/null +++ b/tests/Integration/Notifications/Fixtures/plain.blade.php @@ -0,0 +1 @@ +plainContent diff --git a/tests/Integration/Notifications/SendingMailNotificationsTest.php b/tests/Integration/Notifications/SendingMailNotificationsTest.php index 3be53abc6cf73fb31a902bf27b4fa6748810c243..053087054057fde2488c7bdb89fdc761e21ebe32 100644 --- a/tests/Integration/Notifications/SendingMailNotificationsTest.php +++ b/tests/Integration/Notifications/SendingMailNotificationsTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Notifications; +use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailable; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\Eloquent\Model; @@ -13,13 +14,11 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\View; use Illuminate\Support\Str; use Mockery as m; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class SendingMailNotificationsTest extends TestCase { public $mailer; @@ -34,17 +33,9 @@ class SendingMailNotificationsTest extends TestCase protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - + $this->mailFactory = m::mock(MailFactory::class); $this->mailer = m::mock(Mailer::class); + $this->mailFactory->shouldReceive('mailer')->andReturn($this->mailer); $this->markdown = m::mock(Markdown::class); $app->extend(Markdown::class, function () { @@ -54,6 +45,12 @@ class SendingMailNotificationsTest extends TestCase $app->extend(Mailer::class, function () { return $this->mailer; }); + + $app->extend(MailFactory::class, function () { + return $this->mailFactory; + }); + + View::addLocation(__DIR__.'/Fixtures'); } protected function setUp(): void @@ -193,12 +190,12 @@ class SendingMailNotificationsTest extends TestCase $user->notify($notification); } - public function testMailIsSentToMultipleAdresses() + public function testMailIsSentToMultipleAddresses() { $notification = new TestMailNotificationWithSubject; $notification->id = Str::uuid()->toString(); - $user = NotifiableUserWithMultipleAddreses::forceCreate([ + $user = NotifiableUserWithMultipleAddresses::forceCreate([ 'email' => 'taylor@laravel.com', ]); @@ -238,6 +235,102 @@ class SendingMailNotificationsTest extends TestCase $user->notify($notification); } + + public function testMailIsSentUsingMailMessageWithHtmlAndPlain() + { + $notification = new TestMailNotificationWithHtmlAndPlain; + $notification->id = Str::uuid()->toString(); + + $user = NotifiableUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $this->mailer->shouldReceive('send')->once()->with( + ['html', 'plain'], + array_merge($notification->toMail($user)->toArray(), [ + '__laravel_notification_id' => $notification->id, + '__laravel_notification' => get_class($notification), + '__laravel_notification_queued' => false, + ]), + m::on(function ($closure) { + $message = m::mock(Message::class); + + $message->shouldReceive('to')->once()->with(['taylor@laravel.com']); + + $message->shouldReceive('subject')->once()->with('Test Mail Notification With Html And Plain'); + + $closure($message); + + return true; + }) + ); + + $user->notify($notification); + } + + public function testMailIsSentUsingMailMessageWithHtmlOnly() + { + $notification = new TestMailNotificationWithHtmlOnly; + $notification->id = Str::uuid()->toString(); + + $user = NotifiableUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $this->mailer->shouldReceive('send')->once()->with( + 'html', + array_merge($notification->toMail($user)->toArray(), [ + '__laravel_notification_id' => $notification->id, + '__laravel_notification' => get_class($notification), + '__laravel_notification_queued' => false, + ]), + m::on(function ($closure) { + $message = m::mock(Message::class); + + $message->shouldReceive('to')->once()->with(['taylor@laravel.com']); + + $message->shouldReceive('subject')->once()->with('Test Mail Notification With Html Only'); + + $closure($message); + + return true; + }) + ); + + $user->notify($notification); + } + + public function testMailIsSentUsingMailMessageWithPlainOnly() + { + $notification = new TestMailNotificationWithPlainOnly; + $notification->id = Str::uuid()->toString(); + + $user = NotifiableUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $this->mailer->shouldReceive('send')->once()->with( + [null, 'plain'], + array_merge($notification->toMail($user)->toArray(), [ + '__laravel_notification_id' => $notification->id, + '__laravel_notification' => get_class($notification), + '__laravel_notification_queued' => false, + ]), + m::on(function ($closure) { + $message = m::mock(Message::class); + + $message->shouldReceive('to')->once()->with(['taylor@laravel.com']); + + $message->shouldReceive('subject')->once()->with('Test Mail Notification With Plain Only'); + + $closure($message); + + return true; + }) + ); + + $user->notify($notification); + } } class NotifiableUser extends Model @@ -259,7 +352,7 @@ class NotifiableUserWithNamedAddress extends NotifiableUser } } -class NotifiableUserWithMultipleAddreses extends NotifiableUser +class NotifiableUserWithMultipleAddresses extends NotifiableUser { public function routeNotificationForMail($notification) { @@ -285,7 +378,8 @@ class TestMailNotification extends Notification ->bcc('bcc@deepblue.com', 'bcc') ->from('jack@deepblue.com', 'Jacques Mayol') ->replyTo('jack@deepblue.com', 'Jacques Mayol') - ->line('The introduction to the notification.'); + ->line('The introduction to the notification.') + ->mailer('foo'); } } @@ -320,3 +414,45 @@ class TestMailNotificationWithMailable extends Notification return $mailable; } } + +class TestMailNotificationWithHtmlAndPlain extends Notification +{ + public function via($notifiable) + { + return [MailChannel::class]; + } + + public function toMail($notifiable) + { + return (new MailMessage) + ->view(['html', 'plain']); + } +} + +class TestMailNotificationWithHtmlOnly extends Notification +{ + public function via($notifiable) + { + return [MailChannel::class]; + } + + public function toMail($notifiable) + { + return (new MailMessage) + ->view('html'); + } +} + +class TestMailNotificationWithPlainOnly extends Notification +{ + public function via($notifiable) + { + return [MailChannel::class]; + } + + public function toMail($notifiable) + { + return (new MailMessage) + ->view([null, 'plain']); + } +} diff --git a/tests/Integration/Notifications/SendingNotificationsViaAnonymousNotifiableTest.php b/tests/Integration/Notifications/SendingNotificationsViaAnonymousNotifiableTest.php index af87372c3bdc73231b6b4c23d0a6d8625b364476..d48941336c86ff7bb57c394c041b4e164305a723 100644 --- a/tests/Integration/Notifications/SendingNotificationsViaAnonymousNotifiableTest.php +++ b/tests/Integration/Notifications/SendingNotificationsViaAnonymousNotifiableTest.php @@ -8,18 +8,10 @@ use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Testing\Fakes\NotificationFake; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class SendingNotificationsViaAnonymousNotifiableTest extends TestCase { public $mailer; - protected function getEnvironmentSetUp($app) - { - $app['config']->set('app.debug', 'true'); - } - public function testMailIsSent() { $notifiable = (new AnonymousNotifiable) diff --git a/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php b/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php index daf2cdb655d3cddcc508081f01bf49199cd586b5..5d8e804d1b9cb7bd7a6d5e1d1d58933499fe6f22 100644 --- a/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php +++ b/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Events\LocaleUpdated; -use Illuminate\Foundation\Testing\Assert; use Illuminate\Mail\Mailable; use Illuminate\Notifications\Channels\MailChannel; use Illuminate\Notifications\Messages\MailMessage; @@ -17,31 +16,19 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; +use Illuminate\Testing\Assert; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class SendingNotificationsWithLocaleTest extends TestCase { public $mailer; protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - $app['config']->set('mail.driver', 'array'); $app['config']->set('app.locale', 'en'); - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - View::addLocation(__DIR__.'/Fixtures'); app('translator')->setLoaded([ @@ -76,7 +63,7 @@ class SendingNotificationsWithLocaleTest extends TestCase NotificationFacade::send($user, new GreetingMailNotification); $this->assertStringContainsString('hello', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -90,7 +77,7 @@ class SendingNotificationsWithLocaleTest extends TestCase NotificationFacade::locale('fr')->send($user, new GreetingMailNotification); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -110,11 +97,11 @@ class SendingNotificationsWithLocaleTest extends TestCase NotificationFacade::send($users, (new GreetingMailNotification)->locale('fr')); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[1]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[1]->getBody() ); } @@ -128,7 +115,7 @@ class SendingNotificationsWithLocaleTest extends TestCase NotificationFacade::locale('fr')->send($user, new GreetingMailNotificationWithMailable); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -148,11 +135,11 @@ class SendingNotificationsWithLocaleTest extends TestCase $user->notify((new GreetingMailNotification)->locale('fr')); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); Assert::assertMatchesRegularExpression('/dans (1|un) jour/', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); $this->assertTrue($this->app->isLocale('en')); @@ -170,7 +157,7 @@ class SendingNotificationsWithLocaleTest extends TestCase $recipient->notify(new GreetingMailNotification); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -195,13 +182,13 @@ class SendingNotificationsWithLocaleTest extends TestCase ); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); $this->assertStringContainsString('hola', - app('swift.transport')->messages()[1]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[1]->getBody() ); - $this->assertStringContainsString('hi', - app('swift.transport')->messages()[2]->getBody() + $this->assertStringContainsString('hello', + app('mailer')->getSwiftMailer()->getTransport()->messages()[2]->getBody() ); } @@ -217,7 +204,7 @@ class SendingNotificationsWithLocaleTest extends TestCase ); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } @@ -233,7 +220,7 @@ class SendingNotificationsWithLocaleTest extends TestCase ); $this->assertStringContainsString('bonjour', - app('swift.transport')->messages()[0]->getBody() + app('mailer')->getSwiftMailer()->getTransport()->messages()[0]->getBody() ); } } diff --git a/tests/Integration/Queue/CallQueuedHandlerTest.php b/tests/Integration/Queue/CallQueuedHandlerTest.php index c630c69bf490b44a2119a6704e4ef07ce2aaa55d..97682983bc065a00db92df9651e4a21a72d36ef8 100644 --- a/tests/Integration/Queue/CallQueuedHandlerTest.php +++ b/tests/Integration/Queue/CallQueuedHandlerTest.php @@ -13,9 +13,6 @@ use Illuminate\Support\Facades\Event; use Mockery as m; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class CallQueuedHandlerTest extends TestCase { protected function tearDown(): void @@ -141,25 +138,19 @@ class CallQueuedHandlerTestJob } } -class CallQueuedHandlerTestJobWithMiddleware +/** This exists to test that middleware can also be defined in base classes */ +abstract class AbstractCallQueuedHandlerTestJobWithMiddleware { - use InteractsWithQueue, Queueable; - - public static $handled = false; public static $middlewareCommand; - public function handle() - { - static::$handled = true; - } - public function middleware() { return [ - new class { + new class + { public function handle($command, $next) { - CallQueuedHandlerTestJobWithMiddleware::$middlewareCommand = $command; + AbstractCallQueuedHandlerTestJobWithMiddleware::$middlewareCommand = $command; return $next($command); } @@ -168,6 +159,18 @@ class CallQueuedHandlerTestJobWithMiddleware } } +class CallQueuedHandlerTestJobWithMiddleware extends AbstractCallQueuedHandlerTestJobWithMiddleware +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } +} + class CallQueuedHandlerExceptionThrower { public $deleteWhenMissingModels = true; diff --git a/tests/Integration/Queue/CustomPayloadTest.php b/tests/Integration/Queue/CustomPayloadTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2ce39544be3443a12134dd982380b64201c0d1b2 --- /dev/null +++ b/tests/Integration/Queue/CustomPayloadTest.php @@ -0,0 +1,65 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Contracts\Bus\QueueingDispatcher; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Testing\TestCase; +use Illuminate\Queue\Queue; +use Illuminate\Support\ServiceProvider; +use Orchestra\Testbench\Concerns\CreatesApplication; + +class CustomPayloadTest extends TestCase +{ + use CreatesApplication; + + protected function getPackageProviders($app) + { + return [QueueServiceProvider::class]; + } + + public function websites() + { + yield ['laravel.com']; + + yield ['blog.laravel.com']; + } + + /** + * @dataProvider websites + */ + public function test_custom_payload_gets_cleared_for_each_data_provider(string $websites) + { + $dispatcher = $this->app->make(QueueingDispatcher::class); + + $dispatcher->dispatchToQueue(new MyJob); + } +} + +class QueueServiceProvider extends ServiceProvider +{ + public function register() + { + $this->app->bind('one.time.password', function () { + return random_int(1, 10); + }); + + Queue::createPayloadUsing(function () { + $password = $this->app->make('one.time.password'); + + $this->app->offsetUnset('one.time.password'); + + return ['password' => $password]; + }); + } +} + +class MyJob implements ShouldQueue +{ + public $connection = 'sync'; + + public function handle() + { + // + } +} diff --git a/tests/Integration/Queue/JobChainingTest.php b/tests/Integration/Queue/JobChainingTest.php index 205162b6a7da0b1c78d458f62b5a17425903bbe2..b0ad447768f36bf16e17ab38429c852f09c59b88 100644 --- a/tests/Integration/Queue/JobChainingTest.php +++ b/tests/Integration/Queue/JobChainingTest.php @@ -6,20 +6,16 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Queue; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class JobChainingTest extends TestCase { + public static $catchCallbackRan = false; + protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - $app['config']->set('queue.connections.sync1', [ 'driver' => 'sync', ]); @@ -34,6 +30,7 @@ class JobChainingTest extends TestCase JobChainingTestFirstJob::$ran = false; JobChainingTestSecondJob::$ran = false; JobChainingTestThirdJob::$ran = false; + static::$catchCallbackRan = false; } public function testJobsCanBeChainedOnSuccess() @@ -56,6 +53,28 @@ class JobChainingTest extends TestCase $this->assertTrue(JobChainingTestSecondJob::$ran); } + public function testJobsCanBeChainedOnSuccessUsingBusFacade() + { + Bus::dispatchChain([ + new JobChainingTestFirstJob, + new JobChainingTestSecondJob, + ]); + + $this->assertTrue(JobChainingTestFirstJob::$ran); + $this->assertTrue(JobChainingTestSecondJob::$ran); + } + + public function testJobsCanBeChainedOnSuccessUsingBusFacadeAsArguments() + { + Bus::dispatchChain( + new JobChainingTestFirstJob, + new JobChainingTestSecondJob + ); + + $this->assertTrue(JobChainingTestFirstJob::$ran); + $this->assertTrue(JobChainingTestSecondJob::$ran); + } + public function testJobsChainedOnExplicitDelete() { JobChainingTestDeletingJob::dispatch()->chain([ @@ -127,6 +146,21 @@ class JobChainingTest extends TestCase $this->assertFalse(JobChainingTestThirdJob::$ran); } + public function testCatchCallbackIsCalledOnFailure() + { + Bus::chain([ + new JobChainingTestFirstJob, + new JobChainingTestFailingJob, + new JobChainingTestSecondJob, + ])->catch(static function () { + self::$catchCallbackRan = true; + })->dispatch(); + + $this->assertTrue(JobChainingTestFirstJob::$ran); + $this->assertTrue(static::$catchCallbackRan); + $this->assertFalse(JobChainingTestSecondJob::$ran); + } + public function testChainJobsUseSameConfig() { JobChainingTestFirstJob::dispatch()->allOnQueue('some_queue')->allOnConnection('sync1')->chain([ diff --git a/tests/Integration/Queue/JobDispatchingTest.php b/tests/Integration/Queue/JobDispatchingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a910999aeda1fc6d384318618056644d9bb0da23 --- /dev/null +++ b/tests/Integration/Queue/JobDispatchingTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Orchestra\Testbench\TestCase; + +class JobDispatchingTest extends TestCase +{ + protected function tearDown(): void + { + Job::$ran = false; + } + + public function testJobCanUseCustomMethodsAfterDispatch() + { + Job::dispatch('test')->replaceValue('new-test'); + + $this->assertTrue(Job::$ran); + $this->assertSame('new-test', Job::$value); + } +} + +class Job implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + public static $usedQueue = null; + public static $usedConnection = null; + public static $value = null; + + public function __construct($value) + { + static::$value = $value; + } + + public function handle() + { + static::$ran = true; + } + + public function replaceValue($value) + { + static::$value = $value; + } +} diff --git a/tests/Integration/Queue/JobEncryptionTest.php b/tests/Integration/Queue/JobEncryptionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0bf9d15fa5628585ba178583637baec4ee30dc07 --- /dev/null +++ b/tests/Integration/Queue/JobEncryptionTest.php @@ -0,0 +1,115 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; +use Illuminate\Tests\Integration\Database\DatabaseTestCase; + +class JobEncryptionTest extends DatabaseTestCase +{ + protected function getEnvironmentSetUp($app) + { + parent::getEnvironmentSetUp($app); + + $app['config']->set('app.key', Str::random(32)); + $app['config']->set('queue.default', 'database'); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create('jobs', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + protected function tearDown(): void + { + JobEncryptionTestEncryptedJob::$ran = false; + JobEncryptionTestNonEncryptedJob::$ran = false; + + parent::tearDown(); + } + + public function testEncryptedJobPayloadIsStoredEncrypted() + { + Bus::dispatch(new JobEncryptionTestEncryptedJob); + + $this->assertNotEmpty( + decrypt(json_decode(DB::table('jobs')->first()->payload)->data->command) + ); + } + + public function testNonEncryptedJobPayloadIsStoredRaw() + { + Bus::dispatch(new JobEncryptionTestNonEncryptedJob); + + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('The payload is invalid'); + + $this->assertInstanceOf(JobEncryptionTestNonEncryptedJob::class, + unserialize(json_decode(DB::table('jobs')->first()->payload)->data->command) + ); + + decrypt(json_decode(DB::table('jobs')->first()->payload)->data->command); + } + + public function testQueueCanProcessEncryptedJob() + { + Bus::dispatch(new JobEncryptionTestEncryptedJob); + + Queue::pop()->fire(); + + $this->assertTrue(JobEncryptionTestEncryptedJob::$ran); + } + + public function testQueueCanProcessUnEncryptedJob() + { + Bus::dispatch(new JobEncryptionTestNonEncryptedJob); + + Queue::pop()->fire(); + + $this->assertTrue(JobEncryptionTestNonEncryptedJob::$ran); + } +} + +class JobEncryptionTestEncryptedJob implements ShouldQueue, ShouldBeEncrypted +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} + +class JobEncryptionTestNonEncryptedJob implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} diff --git a/tests/Integration/Queue/ModelSerializationTest.php b/tests/Integration/Queue/ModelSerializationTest.php index 73b682a8a52bcc24e07d1c427cee41041182d823..e14954794d2934bf7677500ba3812700cb55866c 100644 --- a/tests/Integration/Queue/ModelSerializationTest.php +++ b/tests/Integration/Queue/ModelSerializationTest.php @@ -11,23 +11,10 @@ use LogicException; use Orchestra\Testbench\TestCase; use Schema; -/** - * @group integration - */ class ModelSerializationTest extends TestCase { protected function getEnvironmentSetUp($app) { - $app['config']->set('app.debug', 'true'); - - $app['config']->set('database.default', 'testbench'); - - $app['config']->set('database.connections.testbench', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - $app['config']->set('database.connections.custom', [ 'driver' => 'sqlite', 'database' => ':memory:', @@ -87,16 +74,16 @@ class ModelSerializationTest extends TestCase $unSerialized = unserialize($serialized); - $this->assertSame('testbench', $unSerialized->user->getConnectionName()); + $this->assertSame('testing', $unSerialized->user->getConnectionName()); $this->assertSame('mohamed@laravel.com', $unSerialized->user->email); - $serialized = serialize(new CollectionSerializationTestClass(ModelSerializationTestUser::on('testbench')->get())); + $serialized = serialize(new CollectionSerializationTestClass(ModelSerializationTestUser::on('testing')->get())); $unSerialized = unserialize($serialized); - $this->assertSame('testbench', $unSerialized->users[0]->getConnectionName()); + $this->assertSame('testing', $unSerialized->users[0]->getConnectionName()); $this->assertSame('mohamed@laravel.com', $unSerialized->users[0]->email); - $this->assertSame('testbench', $unSerialized->users[1]->getConnectionName()); + $this->assertSame('testing', $unSerialized->users[1]->getConnectionName()); $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } @@ -187,6 +174,23 @@ class ModelSerializationTest extends TestCase $this->assertEquals($nestedUnSerialized->order->getRelations(), $order->getRelations()); } + public function testItCanRunModelBootsAndTraitInitializations() + { + $model = new ModelBootTestWithTraitInitialization(); + + $this->assertTrue($model->fooBar); + $this->assertTrue($model::hasGlobalScope('foo_bar')); + + $model::clearBootedModels(); + + $this->assertFalse($model::hasGlobalScope('foo_bar')); + + $unSerializedModel = unserialize(serialize($model)); + + $this->assertFalse($unSerializedModel->fooBar); + $this->assertTrue($model::hasGlobalScope('foo_bar')); + } + /** * Regression test for https://github.com/laravel/framework/issues/23068. */ @@ -232,8 +236,8 @@ class ModelSerializationTest extends TestCase $unserialized = unserialize($serialized); - $this->assertEquals($unserialized->users->first()->email, 'taylor@laravel.com'); - $this->assertEquals($unserialized->users->last()->email, 'mohamed@laravel.com'); + $this->assertSame('taylor@laravel.com', $unserialized->users->first()->email); + $this->assertSame('mohamed@laravel.com', $unserialized->users->last()->email); } public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedModels() @@ -252,8 +256,8 @@ class ModelSerializationTest extends TestCase $this->assertCount(2, $unserialized->users); - $this->assertEquals($unserialized->users->first()->email, '3@laravel.com'); - $this->assertEquals($unserialized->users->last()->email, '1@laravel.com'); + $this->assertSame('3@laravel.com', $unserialized->users->first()->email); + $this->assertSame('1@laravel.com', $unserialized->users->last()->email); } public function testItCanUnserializeCustomCollection() @@ -270,12 +274,11 @@ class ModelSerializationTest extends TestCase $this->assertInstanceOf(ModelSerializationTestCustomUserCollection::class, $unserialized->users); } + /** + * @requires PHP >= 7.4 + */ public function testItSerializesTypedProperties() { - if (version_compare(phpversion(), '7.4.0-dev', '<')) { - $this->markTestSkipped('Typed properties are only available from PHP 7.4 and up.'); - } - require_once __DIR__.'/typed-properties.php'; $user = ModelSerializationTestUser::create([ @@ -290,18 +293,18 @@ class ModelSerializationTest extends TestCase $unSerialized = unserialize($serialized); - $this->assertSame('testbench', $unSerialized->user->getConnectionName()); + $this->assertSame('testing', $unSerialized->user->getConnectionName()); $this->assertSame('mohamed@laravel.com', $unSerialized->user->email); $this->assertSame(5, $unSerialized->getId()); $this->assertSame(['James', 'Taylor', 'Mohamed'], $unSerialized->getNames()); - $serialized = serialize(new TypedPropertyCollectionTestClass(ModelSerializationTestUser::on('testbench')->get())); + $serialized = serialize(new TypedPropertyCollectionTestClass(ModelSerializationTestUser::on('testing')->get())); $unSerialized = unserialize($serialized); - $this->assertSame('testbench', $unSerialized->users[0]->getConnectionName()); + $this->assertSame('testing', $unSerialized->users[0]->getConnectionName()); $this->assertSame('mohamed@laravel.com', $unSerialized->users[0]->email); - $this->assertSame('testbench', $unSerialized->users[1]->getConnectionName()); + $this->assertSame('testing', $unSerialized->users[1]->getConnectionName()); $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } @@ -313,16 +316,37 @@ class ModelSerializationTest extends TestCase $serialized = serialize(new ModelSerializationParentAccessibleTestClass($user, $user, $user)); - $this->assertEquals( - 'O:78:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationParentAccessibleTestClass":2:{s:4:"user";O:45:"Illuminate\\Contracts\\Database\\ModelIdentifier":4:{s:5:"class";s:61:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationTestUser";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:9:"testbench";}s:8:"'."\0".'*'."\0".'user2";O:45:"Illuminate\\Contracts\\Database\\ModelIdentifier":4:{s:5:"class";s:61:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationTestUser";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:9:"testbench";}}', $serialized + $this->assertSame( + 'O:78:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationParentAccessibleTestClass":2:{s:4:"user";O:45:"Illuminate\\Contracts\\Database\\ModelIdentifier":4:{s:5:"class";s:61:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationTestUser";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";}s:8:"'."\0".'*'."\0".'user2";O:45:"Illuminate\\Contracts\\Database\\ModelIdentifier":4:{s:5:"class";s:61:"Illuminate\\Tests\\Integration\\Queue\\ModelSerializationTestUser";s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";}}', $serialized ); } } +trait TraitBootsAndInitializersTest +{ + public $fooBar = false; + + public function initializeTraitBootsAndInitializersTest() + { + $this->fooBar = ! $this->fooBar; + } + + public static function bootTraitBootsAndInitializersTest() + { + static::addGlobalScope('foo_bar', function () { + }); + } +} + +class ModelBootTestWithTraitInitialization extends Model +{ + use TraitBootsAndInitializersTest; +} + class ModelSerializationTestUser extends Model { public $table = 'users'; - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; } @@ -334,7 +358,7 @@ class ModelSerializationTestCustomUserCollection extends Collection class ModelSerializationTestCustomUser extends Model { public $table = 'users'; - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function newCollection(array $models = []) @@ -345,7 +369,7 @@ class ModelSerializationTestCustomUser extends Model class Order extends Model { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function line() @@ -366,7 +390,7 @@ class Order extends Model class Line extends Model { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function product() @@ -377,13 +401,13 @@ class Line extends Model class Product extends Model { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; } class User extends Model { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function roles() @@ -395,7 +419,7 @@ class User extends Model class Role extends Model { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function users() @@ -407,7 +431,7 @@ class Role extends Model class RoleUser extends Pivot { - public $guarded = ['id']; + public $guarded = []; public $timestamps = false; public function user() diff --git a/tests/Integration/Queue/QueueConnectionTest.php b/tests/Integration/Queue/QueueConnectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..09ee80e11563a8909a545a891fbc5ec99fa2d7d9 --- /dev/null +++ b/tests/Integration/Queue/QueueConnectionTest.php @@ -0,0 +1,86 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\DatabaseTransactionsManager; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Support\Facades\Bus; +use Mockery as m; +use Orchestra\Testbench\TestCase; +use Throwable; + +class QueueConnectionTest extends TestCase +{ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('queue.default', 'sqs'); + $app['config']->set('queue.connections.sqs.after_commit', true); + } + + protected function tearDown(): void + { + QueueConnectionTestJob::$ran = false; + + m::close(); + } + + public function testJobWontGetDispatchedInsideATransaction() + { + $this->app->singleton('db.transactions', function () { + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); + + return $transactionManager; + }); + + Bus::dispatch(new QueueConnectionTestJob); + } + + public function testJobWillGetDispatchedInsideATransactionWhenExplicitlyIndicated() + { + $this->app->singleton('db.transactions', function () { + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldNotReceive('addCallback')->andReturn(null); + + return $transactionManager; + }); + + try { + Bus::dispatch((new QueueConnectionTestJob)->beforeCommit()); + } catch (Throwable $e) { + // This job was dispatched + } + } + + public function testJobWontGetDispatchedInsideATransactionWhenExplicitlyIndicated() + { + $this->app['config']->set('queue.connections.sqs.after_commit', false); + + $this->app->singleton('db.transactions', function () { + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); + + return $transactionManager; + }); + + try { + Bus::dispatch((new QueueConnectionTestJob)->afterCommit()); + } catch (SqsException $e) { + // This job was dispatched + } + } +} + +class QueueConnectionTestJob implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} diff --git a/tests/Integration/Queue/QueuedListenersTest.php b/tests/Integration/Queue/QueuedListenersTest.php index 75c58d55ea40be4ebb7573d293cc35c2bda78ec2..6268ac040549654aeb174d504324f2f10e5ed267 100644 --- a/tests/Integration/Queue/QueuedListenersTest.php +++ b/tests/Integration/Queue/QueuedListenersTest.php @@ -8,9 +8,6 @@ use Illuminate\Events\CallQueuedListener; use Orchestra\Testbench\TestCase; use Queue; -/** - * @group integration - */ class QueuedListenersTest extends TestCase { public function testListenersCanBeQueuedOptionally() diff --git a/tests/Integration/Queue/RateLimitedTest.php b/tests/Integration/Queue/RateLimitedTest.php new file mode 100644 index 0000000000000000000000000000000000000000..80fc594fcd91715297c1aa0537dfcdc93589605b --- /dev/null +++ b/tests/Integration/Queue/RateLimitedTest.php @@ -0,0 +1,232 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Dispatcher; +use Illuminate\Bus\Queueable; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\CallQueuedHandler; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\RateLimited; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class RateLimitedTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testUnlimitedJobsAreExecuted() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $rateLimiter->for('test', function ($job) { + return Limit::none(); + }); + + $this->assertJobRanSuccessfully(RateLimitedTestJob::class); + $this->assertJobRanSuccessfully(RateLimitedTestJob::class); + } + + public function testRateLimitedJobsAreNotExecutedOnLimitReached2() + { + $cache = m::mock(Cache::class); + $cache->shouldReceive('get')->andReturn(0, 1, null); + $cache->shouldReceive('add')->andReturn(true, true); + $cache->shouldReceive('increment')->andReturn(1); + $cache->shouldReceive('has')->andReturn(true); + + $rateLimiter = new RateLimiter($cache); + $this->app->instance(RateLimiter::class, $rateLimiter); + $rateLimiter = $this->app->make(RateLimiter::class); + + $rateLimiter->for('test', function ($job) { + return Limit::perHour(1); + }); + + $this->assertJobRanSuccessfully(RateLimitedTestJob::class); + + // Assert Job was released and released with a delay greater than 0 + RateLimitedTestJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->once()->withArgs(function ($delay) { + return $delay >= 0; + }); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new RateLimitedTestJob), + ]); + + $this->assertFalse(RateLimitedTestJob::$handled); + } + + public function testRateLimitedJobsAreNotExecutedOnLimitReached() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $rateLimiter->for('test', function ($job) { + return Limit::perHour(1); + }); + + $this->assertJobRanSuccessfully(RateLimitedTestJob::class); + $this->assertJobWasReleased(RateLimitedTestJob::class); + } + + public function testRateLimitedJobsCanBeSkippedOnLimitReached() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $rateLimiter->for('test', function ($job) { + return Limit::perHour(1); + }); + + $this->assertJobRanSuccessfully(RateLimitedDontReleaseTestJob::class); + $this->assertJobWasSkipped(RateLimitedDontReleaseTestJob::class); + } + + public function testJobsCanHaveConditionalRateLimits() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $rateLimiter->for('test', function ($job) { + if ($job->isAdmin()) { + return Limit::none(); + } + + return Limit::perHour(1); + }); + + $this->assertJobRanSuccessfully(AdminTestJob::class); + $this->assertJobRanSuccessfully(AdminTestJob::class); + + $this->assertJobRanSuccessfully(NonAdminTestJob::class); + $this->assertJobWasReleased(NonAdminTestJob::class); + } + + public function testMiddlewareSerialization() + { + $rateLimited = new RateLimited('limiterName'); + $rateLimited->shouldRelease = false; + + $restoredRateLimited = unserialize(serialize($rateLimited)); + + $fetch = (function (string $name) { + return $this->{$name}; + })->bindTo($restoredRateLimited, RateLimited::class); + + $this->assertFalse($restoredRateLimited->shouldRelease); + $this->assertSame('limiterName', $fetch('limiterName')); + $this->assertInstanceOf(RateLimiter::class, $fetch('limiter')); + } + + protected function assertJobRanSuccessfully($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } + + protected function assertJobWasReleased($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertFalse($class::$handled); + } + + protected function assertJobWasSkipped($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertFalse($class::$handled); + } +} + +class RateLimitedTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [new RateLimited('test')]; + } +} + +class AdminTestJob extends RateLimitedTestJob +{ + public function isAdmin() + { + return true; + } +} + +class NonAdminTestJob extends RateLimitedTestJob +{ + public function isAdmin() + { + return false; + } +} + +class RateLimitedDontReleaseTestJob extends RateLimitedTestJob +{ + public function middleware() + { + return [(new RateLimited('test'))->dontRelease()]; + } +} diff --git a/tests/Integration/Queue/RateLimitedWithRedisTest.php b/tests/Integration/Queue/RateLimitedWithRedisTest.php new file mode 100644 index 0000000000000000000000000000000000000000..768b3c2db9b06fe7025a217bbf56c494fbaa30a6 --- /dev/null +++ b/tests/Integration/Queue/RateLimitedWithRedisTest.php @@ -0,0 +1,233 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Dispatcher; +use Illuminate\Bus\Queueable; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Contracts\Redis\Factory as Redis; +use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; +use Illuminate\Queue\CallQueuedHandler; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\RateLimitedWithRedis; +use Illuminate\Support\Str; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class RateLimitedWithRedisTest extends TestCase +{ + use InteractsWithRedis; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpRedis(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownRedis(); + + m::close(); + } + + public function testUnlimitedJobsAreExecuted() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $testJob = new RedisRateLimitedTestJob; + + $rateLimiter->for($testJob->key, function ($job) { + return Limit::none(); + }); + + $this->assertJobRanSuccessfully($testJob); + $this->assertJobRanSuccessfully($testJob); + } + + public function testRateLimitedJobsAreNotExecutedOnLimitReached() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $testJob = new RedisRateLimitedTestJob; + + $rateLimiter->for($testJob->key, function ($job) { + return Limit::perMinute(1); + }); + + $this->assertJobRanSuccessfully($testJob); + $this->assertJobWasReleased($testJob); + } + + public function testRateLimitedJobsCanBeSkippedOnLimitReached() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $testJob = new RedisRateLimitedDontReleaseTestJob; + + $rateLimiter->for($testJob->key, function ($job) { + return Limit::perMinute(1); + }); + + $this->assertJobRanSuccessfully($testJob); + $this->assertJobWasSkipped($testJob); + } + + public function testJobsCanHaveConditionalRateLimits() + { + $rateLimiter = $this->app->make(RateLimiter::class); + + $adminJob = new RedisAdminTestJob; + + $rateLimiter->for($adminJob->key, function ($job) { + if ($job->isAdmin()) { + return Limit::none(); + } + + return Limit::perMinute(1); + }); + + $this->assertJobRanSuccessfully($adminJob); + $this->assertJobRanSuccessfully($adminJob); + + $nonAdminJob = new RedisNonAdminTestJob; + + $rateLimiter->for($nonAdminJob->key, function ($job) { + if ($job->isAdmin()) { + return Limit::none(); + } + + return Limit::perMinute(1); + }); + + $this->assertJobRanSuccessfully($nonAdminJob); + $this->assertJobWasReleased($nonAdminJob); + } + + public function testMiddlewareSerialization() + { + $rateLimited = new RateLimitedWithRedis('limiterName'); + $rateLimited->shouldRelease = false; + + $restoredRateLimited = unserialize(serialize($rateLimited)); + + $fetch = (function (string $name) { + return $this->{$name}; + })->bindTo($restoredRateLimited, RateLimitedWithRedis::class); + + $this->assertFalse($restoredRateLimited->shouldRelease); + $this->assertSame('limiterName', $fetch('limiterName')); + $this->assertInstanceOf(RateLimiter::class, $fetch('limiter')); + $this->assertInstanceOf(Redis::class, $fetch('redis')); + } + + protected function assertJobRanSuccessfully($testJob) + { + $testJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($testJob), + ]); + + $this->assertTrue($testJob::$handled); + } + + protected function assertJobWasReleased($testJob) + { + $testJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($testJob), + ]); + + $this->assertFalse($testJob::$handled); + } + + protected function assertJobWasSkipped($testJob) + { + $testJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($testJob), + ]); + + $this->assertFalse($testJob::$handled); + } +} + +class RedisRateLimitedTestJob +{ + use InteractsWithQueue, Queueable; + + public $key; + + public static $handled = false; + + public function __construct() + { + $this->key = Str::random(10); + } + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [new RateLimitedWithRedis($this->key)]; + } +} + +class RedisAdminTestJob extends RedisRateLimitedTestJob +{ + public function isAdmin() + { + return true; + } +} + +class RedisNonAdminTestJob extends RedisRateLimitedTestJob +{ + public function isAdmin() + { + return false; + } +} + +class RedisRateLimitedDontReleaseTestJob extends RedisRateLimitedTestJob +{ + public function middleware() + { + return [(new RateLimitedWithRedis($this->key))->dontRelease()]; + } +} diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6eff31a6aabd15660dd51615a5375bd960c41d0a --- /dev/null +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -0,0 +1,144 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Exception; +use Illuminate\Bus\Dispatcher; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\CallQueuedHandler; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\ThrottlesExceptions; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class ThrottlesExceptionsTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testCircuitIsOpenedForJobErrors() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedWithDelay(CircuitBreakerTestJob::class); + } + + public function testCircuitStaysClosedForSuccessfulJobs() + { + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + } + + public function testCircuitResetsAfterSuccess() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedWithDelay(CircuitBreakerTestJob::class); + } + + protected function assertJobWasReleasedImmediately($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->with(0)->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } + + protected function assertJobWasReleasedWithDelay($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->withArgs(function ($delay) { + return $delay >= 600; + })->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertFalse($class::$handled); + } + + protected function assertJobRanSuccessfully($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } +} + +class CircuitBreakerTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + + throw new Exception; + } + + public function middleware() + { + return [(new ThrottlesExceptions(2, 10))->by('test')]; + } +} + +class CircuitBreakerSuccessfulJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [(new ThrottlesExceptions(2, 10))->by('test')]; + } +} diff --git a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f6d1146ff01095bcf62f0760dfe09c894fb56ab4 --- /dev/null +++ b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php @@ -0,0 +1,164 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Exception; +use Illuminate\Bus\Dispatcher; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; +use Illuminate\Queue\CallQueuedHandler; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis; +use Illuminate\Support\Str; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class ThrottlesExceptionsWithRedisTest extends TestCase +{ + use InteractsWithRedis; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpRedis(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownRedis(); + + m::close(); + } + + public function testCircuitIsOpenedForJobErrors() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + } + + public function testCircuitStaysClosedForSuccessfulJobs() + { + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + } + + public function testCircuitResetsAfterSuccess() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + } + + protected function assertJobWasReleasedImmediately($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->with(0)->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertTrue($class::$handled); + } + + protected function assertJobWasReleasedWithDelay($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->withArgs(function ($delay) { + return $delay >= 600; + })->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertFalse($class::$handled); + } + + protected function assertJobRanSuccessfully($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertTrue($class::$handled); + } +} + +class CircuitBreakerWithRedisTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function __construct($key) + { + $this->key = $key; + } + + public function handle() + { + static::$handled = true; + + throw new Exception; + } + + public function middleware() + { + return [(new ThrottlesExceptionsWithRedis(2, 10))->by($this->key)]; + } +} + +class CircuitBreakerWithRedisSuccessfulJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function __construct($key) + { + $this->key = $key; + } + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [(new ThrottlesExceptionsWithRedis(2, 10))->by($this->key)]; + } +} diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..779c0f9e4a16659f8f0cbeb04e5593b237b52e9e --- /dev/null +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -0,0 +1,211 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Exception; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Bus; +use Orchestra\Testbench\TestCase; + +class UniqueJobTest extends TestCase +{ + protected function getEnvironmentSetUp($app) + { + $app['db']->connection()->getSchemaBuilder()->create('jobs', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('queue'); + $table->longText('payload'); + $table->tinyInteger('attempts')->unsigned(); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + $table->index(['queue', 'reserved_at']); + }); + } + + protected function tearDown(): void + { + $this->app['db']->connection()->getSchemaBuilder()->drop('jobs'); + + parent::tearDown(); + } + + public function testUniqueJobsAreNotDispatched() + { + Bus::fake(); + + UniqueTestJob::dispatch(); + Bus::assertDispatched(UniqueTestJob::class); + + $this->assertFalse( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + + Bus::fake(); + UniqueTestJob::dispatch(); + Bus::assertNotDispatched(UniqueTestJob::class); + + $this->assertFalse( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + } + + public function testLockIsReleasedForSuccessfulJobs() + { + UniqueTestJob::$handled = false; + dispatch($job = new UniqueTestJob); + + $this->assertTrue($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + + public function testLockIsReleasedForFailedJobs() + { + UniqueTestFailJob::$handled = false; + + $this->expectException(Exception::class); + + try { + dispatch($job = new UniqueTestFailJob); + } finally { + $this->assertTrue($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + } + + public function testLockIsNotReleasedForJobRetries() + { + UniqueTestRetryJob::$handled = false; + + dispatch($job = new UniqueTestRetryJob); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + ]); + + $this->assertTrue($job::$handled); + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + UniqueTestRetryJob::$handled = false; + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + ]); + + $this->assertTrue($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + + public function testLockIsNotReleasedForJobReleases() + { + UniqueTestReleasedJob::$handled = false; + dispatch($job = new UniqueTestReleasedJob); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + ]); + + $this->assertTrue($job::$handled); + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + UniqueTestReleasedJob::$handled = false; + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + ]); + + $this->assertFalse($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + + public function testLockCanBeReleasedBeforeProcessing() + { + UniqueUntilStartTestJob::$handled = false; + + dispatch($job = new UniqueUntilStartTestJob); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + ]); + + $this->assertTrue($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + + protected function getLockKey($job) + { + return 'laravel_unique_job:'.(is_string($job) ? $job : get_class($job)); + } +} + +class UniqueTestJob implements ShouldQueue, ShouldBeUnique +{ + use InteractsWithQueue, Queueable, Dispatchable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } +} + +class UniqueTestFailJob implements ShouldQueue, ShouldBeUnique +{ + use InteractsWithQueue, Queueable, Dispatchable; + + public $tries = 1; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + + throw new Exception; + } +} + +class UniqueTestReleasedJob extends UniqueTestFailJob +{ + public $tries = 1; + + public $connection = 'database'; + + public function handle() + { + static::$handled = true; + + $this->release(); + } +} + +class UniqueTestRetryJob extends UniqueTestFailJob +{ + public $tries = 2; + + public $connection = 'database'; +} + +class UniqueUntilStartTestJob extends UniqueTestJob implements ShouldBeUniqueUntilProcessing +{ + public $tries = 2; + + public $connection = 'database'; +} diff --git a/tests/Integration/Queue/WithoutOverlappingJobsTest.php b/tests/Integration/Queue/WithoutOverlappingJobsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1dded8c1185e368befcd9cd56df5f745b7627c7a --- /dev/null +++ b/tests/Integration/Queue/WithoutOverlappingJobsTest.php @@ -0,0 +1,150 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Exception; +use Illuminate\Bus\Dispatcher; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\CallQueuedHandler; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\WithoutOverlapping; +use Mockery as m; +use Orchestra\Testbench\TestCase; + +class WithoutOverlappingJobsTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testNonOverlappingJobsAreExecuted() + { + OverlappingTestJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command = new OverlappingTestJob), + ]); + + $lockKey = (new WithoutOverlapping)->getLockKey($command); + + $this->assertTrue(OverlappingTestJob::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($lockKey, 10)->acquire()); + } + + public function testLockIsReleasedOnJobExceptions() + { + FailedOverlappingTestJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->andReturn(false); + + $this->expectException(Exception::class); + + try { + $instance->call($job, [ + 'command' => serialize($command = new FailedOverlappingTestJob), + ]); + } finally { + $lockKey = (new WithoutOverlapping)->getLockKey($command); + + $this->assertTrue(FailedOverlappingTestJob::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($lockKey, 10)->acquire()); + } + } + + public function testOverlappingJobsAreReleased() + { + OverlappingTestJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $lockKey = (new WithoutOverlapping)->getLockKey($command = new OverlappingTestJob); + $this->app->get(Cache::class)->lock($lockKey, 10)->acquire(); + + $job = m::mock(Job::class); + + $job->shouldReceive('release')->once(); + $job->shouldReceive('hasFailed')->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command), + ]); + + $this->assertFalse(OverlappingTestJob::$handled); + } + + public function testOverlappingJobsCanBeSkipped() + { + SkipOverlappingTestJob::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $lockKey = (new WithoutOverlapping)->getLockKey($command = new SkipOverlappingTestJob); + $this->app->get(Cache::class)->lock($lockKey, 10)->acquire(); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command), + ]); + + $this->assertFalse(SkipOverlappingTestJob::$handled); + } +} + +class OverlappingTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [new WithoutOverlapping]; + } +} + +class SkipOverlappingTestJob extends OverlappingTestJob +{ + public function middleware() + { + return [(new WithoutOverlapping)->dontRelease()]; + } +} + +class FailedOverlappingTestJob extends OverlappingTestJob +{ + public function handle() + { + static::$handled = true; + + throw new Exception; + } +} diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..13ba211d58e45533c52dc3aacdfb6f8c8ab2f427 --- /dev/null +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -0,0 +1,165 @@ +<?php + +namespace Illuminate\Tests\Integration\Queue; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Foundation\Bus\Dispatchable; +use Orchestra\Testbench\TestCase; +use Queue; + +class WorkCommandTest extends TestCase +{ + protected function getEnvironmentSetUp($app) + { + $app['db']->connection()->getSchemaBuilder()->create('jobs', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('queue'); + $table->longText('payload'); + $table->tinyInteger('attempts')->unsigned(); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + $table->index(['queue', 'reserved_at']); + }); + } + + protected function tearDown(): void + { + $this->app['db']->connection()->getSchemaBuilder()->drop('jobs'); + + parent::tearDown(); + + FirstJob::$ran = false; + SecondJob::$ran = false; + ThirdJob::$ran = false; + } + + public function testRunningOneJob() + { + Queue::connection('database')->push(new FirstJob); + Queue::connection('database')->push(new SecondJob); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--once' => true, + '--memory' => 1024, + ])->assertExitCode(0); + + $this->assertSame(1, Queue::connection('database')->size()); + $this->assertTrue(FirstJob::$ran); + $this->assertFalse(SecondJob::$ran); + } + + public function testDaemon() + { + Queue::connection('database')->push(new FirstJob); + Queue::connection('database')->push(new SecondJob); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 1024, + ])->assertExitCode(0); + + $this->assertSame(0, Queue::connection('database')->size()); + $this->assertTrue(FirstJob::$ran); + $this->assertTrue(SecondJob::$ran); + } + + public function testMemoryExceeded() + { + Queue::connection('database')->push(new FirstJob); + Queue::connection('database')->push(new SecondJob); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 0.1, + ])->assertExitCode(12); + + // Memory limit isn't checked until after the first job is attempted. + $this->assertSame(1, Queue::connection('database')->size()); + $this->assertTrue(FirstJob::$ran); + $this->assertFalse(SecondJob::$ran); + } + + public function testMaxJobsExceeded() + { + Queue::connection('database')->push(new FirstJob); + Queue::connection('database')->push(new SecondJob); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--daemon' => true, + '--stop-when-empty' => true, + '--max-jobs' => 1, + ]); + + // Memory limit isn't checked until after the first job is attempted. + $this->assertSame(1, Queue::connection('database')->size()); + $this->assertTrue(FirstJob::$ran); + $this->assertFalse(SecondJob::$ran); + } + + public function testMaxTimeExceeded() + { + Queue::connection('database')->push(new ThirdJob); + Queue::connection('database')->push(new FirstJob); + Queue::connection('database')->push(new SecondJob); + + $this->artisan('queue:work', [ + 'connection' => 'database', + '--daemon' => true, + '--stop-when-empty' => true, + '--max-time' => 1, + ]); + + // Memory limit isn't checked until after the first job is attempted. + $this->assertSame(2, Queue::connection('database')->size()); + $this->assertTrue(ThirdJob::$ran); + $this->assertFalse(FirstJob::$ran); + $this->assertFalse(SecondJob::$ran); + } +} + +class FirstJob implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} + +class SecondJob implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + static::$ran = true; + } +} + +class ThirdJob implements ShouldQueue +{ + use Dispatchable, Queueable; + + public static $ran = false; + + public function handle() + { + sleep(1); + + static::$ran = true; + } +} diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fcaa4dad5d4e62b043afe71669f0fd56c0a541ee --- /dev/null +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -0,0 +1,571 @@ +<?php + +namespace Illuminate\Tests\Integration\Routing; + +use ArrayIterator; +use Illuminate\Http\Request; +use Illuminate\Routing\Route; +use Illuminate\Routing\RouteCollection; +use Illuminate\Support\Arr; +use Orchestra\Testbench\TestCase; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +class CompiledRouteCollectionTest extends TestCase +{ + /** + * @var \Illuminate\Routing\RouteCollection + */ + protected $routeCollection; + + /** + * @var \Illuminate\Routing\Router + */ + protected $router; + + protected function setUp(): void + { + parent::setUp(); + + $this->router = $this->app['router']; + + $this->routeCollection = new RouteCollection; + } + + protected function tearDown(): void + { + parent::tearDown(); + + unset($this->routeCollection, $this->router); + } + + /** + * @return \Illuminate\Routing\CompiledRouteCollection + */ + protected function collection() + { + return $this->routeCollection->toCompiledRouteCollection($this->router, $this->app); + } + + public function testRouteCollectionCanAddRoute() + { + $this->routeCollection->add($this->newRoute('GET', 'foo', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $this->assertCount(1, $this->collection()); + } + + public function testRouteCollectionAddReturnsTheRoute() + { + $outputRoute = $this->collection()->add($inputRoute = $this->newRoute('GET', 'foo', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $this->assertInstanceOf(Route::class, $outputRoute); + $this->assertEquals($inputRoute, $outputRoute); + } + + public function testRouteCollectionCanRetrieveByName() + { + $this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'route_name', + ])); + + $routes = $this->collection(); + + $this->assertSame('route_name', $routeIndex->getName()); + $this->assertSame('route_name', $routes->getByName('route_name')->getName()); + $this->assertEquals($routeIndex, $routes->getByName('route_name')); + } + + public function testRouteCollectionCanRetrieveByAction() + { + $this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', $action = [ + 'uses' => 'FooController@index', + ])); + + $route = $this->collection()->getByAction('FooController@index'); + + $this->assertSame($action, Arr::except($routeIndex->getAction(), 'as')); + $this->assertSame($action, Arr::except($route->getAction(), 'as')); + } + + public function testRouteCollectionCanGetIterator() + { + $this->routeCollection->add($this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $this->assertInstanceOf(ArrayIterator::class, $this->collection()->getIterator()); + } + + public function testRouteCollectionCanGetIteratorWhenEmpty() + { + $routes = $this->collection(); + + $this->assertCount(0, $routes); + $this->assertInstanceOf(ArrayIterator::class, $routes->getIterator()); + } + + public function testRouteCollectionCanGetIteratorWhenRoutesAreAdded() + { + $this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $routes = $this->collection(); + + $this->assertCount(1, $routes); + + $this->routeCollection->add($routeShow = $this->newRoute('GET', 'bar/show', [ + 'uses' => 'BarController@show', + 'as' => 'bar_show', + ])); + + $routes = $this->collection(); + + $this->assertCount(2, $routes); + + $this->assertInstanceOf(ArrayIterator::class, $routes->getIterator()); + } + + public function testRouteCollectionCanHandleSameRoute() + { + $this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $routes = $this->collection(); + + $this->assertCount(1, $routes); + + // Add exactly the same route + $this->routeCollection->add($routeIndex); + + $routes = $this->collection(); + + $this->assertCount(1, $routes); + + // Add a non-existing route + $this->routeCollection->add($this->newRoute('GET', 'bar/show', [ + 'uses' => 'BarController@show', + 'as' => 'bar_show', + ])); + + $routes = $this->collection(); + + $this->assertCount(2, $routes); + } + + public function testRouteCollectionCanGetAllRoutes() + { + $this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + $this->routeCollection->add($routeShow = $this->newRoute('GET', 'foo/show', [ + 'uses' => 'FooController@show', + 'as' => 'foo_show', + ])); + $this->routeCollection->add($routeNew = $this->newRoute('POST', 'bar', [ + 'uses' => 'BarController@create', + 'as' => 'bar_create', + ])); + + $allRoutes = [ + $routeIndex, + $routeShow, + $routeNew, + ]; + $this->assertEquals($allRoutes, $this->collection()->getRoutes()); + } + + public function testRouteCollectionCanGetRoutesByName() + { + $routesByName = [ + 'foo_index' => $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ]), + 'foo_show' => $this->newRoute('GET', 'foo/show', [ + 'uses' => 'FooController@show', + 'as' => 'foo_show', + ]), + 'bar_create' => $this->newRoute('POST', 'bar', [ + 'uses' => 'BarController@create', + 'as' => 'bar_create', + ]), + ]; + + $this->routeCollection->add($routesByName['foo_index']); + $this->routeCollection->add($routesByName['foo_show']); + $this->routeCollection->add($routesByName['bar_create']); + + $this->assertEquals($routesByName, $this->collection()->getRoutesByName()); + } + + public function testRouteCollectionCanGetRoutesByMethod() + { + $routes = [ + 'foo_index' => $this->newRoute('GET', 'foo/index', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ]), + 'foo_show' => $this->newRoute('GET', 'foo/show', [ + 'uses' => 'FooController@show', + 'as' => 'foo_show', + ]), + 'bar_create' => $this->newRoute('POST', 'bar', [ + 'uses' => 'BarController@create', + 'as' => 'bar_create', + ]), + ]; + + $this->routeCollection->add($routes['foo_index']); + $this->routeCollection->add($routes['foo_show']); + $this->routeCollection->add($routes['bar_create']); + + $this->assertEquals([ + 'GET' => [ + 'foo/index' => $routes['foo_index'], + 'foo/show' => $routes['foo_show'], + ], + 'HEAD' => [ + 'foo/index' => $routes['foo_index'], + 'foo/show' => $routes['foo_show'], + ], + 'POST' => [ + 'bar' => $routes['bar_create'], + ], + ], $this->collection()->getRoutesByMethod()); + } + + public function testRouteCollectionCleansUpOverwrittenRoutes() + { + // Create two routes with the same path and method. + $routeA = $this->newRoute('GET', 'product', ['controller' => 'View@view', 'as' => 'routeA']); + $routeB = $this->newRoute('GET', 'product', ['controller' => 'OverwrittenView@view', 'as' => 'overwrittenRouteA']); + + $this->routeCollection->add($routeA); + $this->routeCollection->add($routeB); + + // Check if the lookups of $routeA and $routeB are there. + $this->assertEquals($routeA, $this->routeCollection->getByName('routeA')); + $this->assertEquals($routeA, $this->routeCollection->getByAction('View@view')); + $this->assertEquals($routeB, $this->routeCollection->getByName('overwrittenRouteA')); + $this->assertEquals($routeB, $this->routeCollection->getByAction('OverwrittenView@view')); + + $routes = $this->collection(); + + // The lookups of $routeA should not be there anymore, because they are no longer valid. + $this->assertNull($routes->getByName('routeA')); + $this->assertNull($routes->getByAction('View@view')); + // The lookups of $routeB are still there. + $this->assertEquals($routeB, $routes->getByName('overwrittenRouteA')); + $this->assertEquals($routeB, $routes->getByAction('OverwrittenView@view')); + } + + public function testMatchingThrowsNotFoundExceptionWhenRouteIsNotFound() + { + $this->routeCollection->add($this->newRoute('GET', '/', ['uses' => 'FooController@index'])); + + $this->expectException(NotFoundHttpException::class); + + $this->collection()->match(Request::create('/foo')); + } + + public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed() + { + $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); + + $this->expectException(MethodNotAllowedHttpException::class); + + $this->collection()->match(Request::create('/foo', 'POST')); + } + + public function testMatchingThrowsExceptionWhenMethodIsNotAllowedWhileSameRouteIsAddedDynamically() + { + $this->routeCollection->add($this->newRoute('GET', '/', ['uses' => 'FooController@index'])); + + $routes = $this->collection(); + + $routes->add($this->newRoute('POST', '/', ['uses' => 'FooController@index'])); + + $this->expectException(MethodNotAllowedHttpException::class); + + $routes->match(Request::create('/', 'PUT')); + } + + public function testMatchingRouteWithSameDynamicallyAddedRouteAlwaysMatchesCachedOneFirst() + { + $this->routeCollection->add( + $route = $this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $routes = $this->collection(); + + $routes->add($this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'bar'])); + + $this->assertSame('foo', $routes->match(Request::create('/', 'GET'))->getName()); + } + + public function testMatchingFindsRouteWithDifferentMethodDynamically() + { + $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); + + $routes = $this->collection(); + + $routes->add($route = $this->newRoute('POST', '/foo', ['uses' => 'FooController@index'])); + + $this->assertSame($route, $routes->match(Request::create('/foo', 'POST'))); + } + + public function testMatchingWildcardFromCompiledRoutesAlwaysTakesPrecedent() + { + $this->routeCollection->add( + $route = $this->newRoute('GET', '{wildcard}', ['uses' => 'FooController@index', 'as' => 'foo']) + ->where('wildcard', '.*') + ); + + $routes = $this->collection(); + + $routes->add( + $this->newRoute('GET', '{wildcard}', ['uses' => 'FooController@index', 'as' => 'bar']) + ->where('wildcard', '.*') + ); + + $this->assertSame('foo', $routes->match(Request::create('/foo', 'GET'))->getName()); + } + + public function testMatchingDynamicallyAddedRoutesTakePrecedenceOverFallbackRoutes() + { + $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index'])); + $this->routeCollection->add( + $this->newRoute('GET', '/foo/{id}', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $routes = $this->collection(); + + $routes->add($this->newRoute('GET', '/bar/{id}', ['uses' => 'FooController@index', 'as' => 'bar'])); + + $this->assertSame('bar', $routes->match(Request::create('/bar/1', 'GET'))->getName()); + } + + public function testMatchingFallbackRouteCatchesAll() + { + $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index', 'as' => 'fallback'])); + $this->routeCollection->add( + $this->newRoute('GET', '/foo/{id}', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $routes = $this->collection(); + + $routes->add($this->newRoute('GET', '/bar/{id}', ['uses' => 'FooController@index', 'as' => 'bar'])); + + $this->assertSame('fallback', $routes->match(Request::create('/baz/1', 'GET'))->getName()); + } + + public function testMatchingCachedFallbackTakesPrecedenceOverDynamicFallback() + { + $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index', 'as' => 'fallback'])); + + $routes = $this->collection(); + + $routes->add($this->fallbackRoute(['uses' => 'FooController@index', 'as' => 'dynamic_fallback'])); + + $this->assertSame('fallback', $routes->match(Request::create('/baz/1', 'GET'))->getName()); + } + + public function testMatchingCachedFallbackTakesPrecedenceOverDynamicRouteWithWrongMethod() + { + $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index', 'as' => 'fallback'])); + + $routes = $this->collection(); + + $routes->add($this->newRoute('POST', '/bar/{id}', ['uses' => 'FooController@index', 'as' => 'bar'])); + + $this->assertSame('fallback', $routes->match(Request::create('/bar/1', 'GET'))->getName()); + } + + public function testSlashPrefixIsProperlyHandled() + { + $this->routeCollection->add($this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'prefix' => '/'])); + + $route = $this->collection()->getByAction('FooController@index'); + + $this->assertSame('foo/bar', $route->uri()); + } + + public function testRouteWithoutNamespaceIsFound() + { + $this->routeCollection->add($this->newRoute('GET', 'foo/bar', ['controller' => '\App\FooController'])); + + $route = $this->collection()->getByAction('App\FooController'); + + $this->assertSame('foo/bar', $route->uri()); + } + + public function testGroupPrefixAndRoutePrefixAreProperlyHandled() + { + $this->routeCollection->add($this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'prefix' => '{locale}'])->prefix('pre')); + + $route = $this->collection()->getByAction('FooController@index'); + + $this->assertSame('pre/{locale}', $route->getPrefix()); + } + + public function testGroupGenerateNameForDuplicateRouteNamesThatEndWithDot() + { + $this->routeCollection->add($this->newRoute('GET', 'foo', ['uses' => 'FooController@index'])->name('foo.')); + $this->routeCollection->add($route = $this->newRoute('GET', 'bar', ['uses' => 'BarController@index'])->name('foo.')); + + $routes = $this->collection(); + + $this->assertSame('BarController@index', $routes->match(Request::create('/bar', 'GET'))->getAction()['uses']); + } + + public function testRouteBindingsAreProperlySaved() + { + $this->routeCollection->add($this->newRoute('GET', 'posts/{post:slug}/show', [ + 'uses' => 'FooController@index', + 'prefix' => 'profile/{user:username}', + 'as' => 'foo', + ])); + + $route = $this->collection()->getByName('foo'); + + $this->assertSame('profile/{user}/posts/{post}/show', $route->uri()); + $this->assertSame(['user' => 'username', 'post' => 'slug'], $route->bindingFields()); + } + + public function testMatchingSlashedRoutes() + { + $this->routeCollection->add( + $route = $this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $this->assertSame('foo', $this->collection()->match(Request::create('/foo/bar/'))->getName()); + } + + public function testMatchingUriWithQuery() + { + $this->routeCollection->add( + $route = $this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $this->assertSame('foo', $this->collection()->match(Request::create('/foo/bar/?foo=bar'))->getName()); + } + + public function testMatchingRootUri() + { + $this->routeCollection->add( + $route = $this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $this->assertSame('foo', $this->collection()->match(Request::create('http://example.com'))->getName()); + } + + public function testTrailingSlashIsTrimmedWhenMatchingCachedRoutes() + { + $this->routeCollection->add( + $this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'as' => 'foo']) + ); + + $request = Request::create('/foo/bar/'); + + // Access to request path info before matching route + $request->getPathInfo(); + + $this->assertSame('foo', $this->collection()->match($request)->getName()); + } + + public function testRouteWithSamePathAndSameMethodButDiffDomainNameWithOptionsMethod() + { + $routes = [ + 'foo_domain' => $this->newRoute('GET', 'same/path', [ + 'uses' => 'FooController@index', + 'as' => 'foo', + 'domain' => 'foo.localhost', + ]), + 'bar_domain' => $this->newRoute('GET', 'same/path', [ + 'uses' => 'BarController@index', + 'as' => 'bar', + 'domain' => 'bar.localhost', + ]), + 'no_domain' => $this->newRoute('GET', 'same/path', [ + 'uses' => 'BarController@index', + 'as' => 'no_domain', + ]), + ]; + + $this->routeCollection->add($routes['foo_domain']); + $this->routeCollection->add($routes['bar_domain']); + $this->routeCollection->add($routes['no_domain']); + + $expectedMethods = [ + 'OPTIONS', + ]; + + $this->assertSame($expectedMethods, $this->collection()->match( + Request::create('http://foo.localhost/same/path', 'OPTIONS') + )->methods); + + $this->assertSame($expectedMethods, $this->collection()->match( + Request::create('http://bar.localhost/same/path', 'OPTIONS') + )->methods); + + $this->assertSame($expectedMethods, $this->collection()->match( + Request::create('http://no.localhost/same/path', 'OPTIONS') + )->methods); + + $this->assertEquals([ + 'HEAD' => [ + 'foo.localhostsame/path' => $routes['foo_domain'], + 'bar.localhostsame/path' => $routes['bar_domain'], + 'same/path' => $routes['no_domain'], + ], + 'GET' => [ + 'foo.localhostsame/path' => $routes['foo_domain'], + 'bar.localhostsame/path' => $routes['bar_domain'], + 'same/path' => $routes['no_domain'], + ], + ], $this->collection()->getRoutesByMethod()); + } + + /** + * Create a new Route object. + * + * @param array|string $methods + * @param string $uri + * @param mixed $action + * @return \Illuminate\Routing\Route + */ + protected function newRoute($methods, $uri, $action) + { + return (new Route($methods, $uri, $action)) + ->setRouter($this->router) + ->setContainer($this->app); + } + + /** + * Create a new fallback Route object. + * + * @param mixed $action + * @return \Illuminate\Routing\Route + */ + protected function fallbackRoute($action) + { + $placeholder = 'fallbackPlaceholder'; + + return $this->newRoute( + 'GET', "{{$placeholder}}", $action + )->where($placeholder, '.*')->fallback(); + } +} diff --git a/tests/Integration/Routing/FallbackRouteTest.php b/tests/Integration/Routing/FallbackRouteTest.php index 62776ccbc835a7291c25461ca65c41d406694cf0..110eaf5174732b71c13d65cee5e6f0cb80bc29ed 100644 --- a/tests/Integration/Routing/FallbackRouteTest.php +++ b/tests/Integration/Routing/FallbackRouteTest.php @@ -5,9 +5,6 @@ namespace Illuminate\Tests\Integration\Routing; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class FallbackRouteTest extends TestCase { public function testBasicFallback() diff --git a/tests/Integration/Routing/Fixtures/redirect_routes.php b/tests/Integration/Routing/Fixtures/redirect_routes.php new file mode 100644 index 0000000000000000000000000000000000000000..9eed7296627ae4153552146084adaaabf60dea84 --- /dev/null +++ b/tests/Integration/Routing/Fixtures/redirect_routes.php @@ -0,0 +1,13 @@ +<?php + +use Illuminate\Support\Facades\Route; + +Route::redirect('/foo/1', '/foo/1/bar'); + +Route::get('/foo/1/bar', function () { + return 'Redirect response'; +}); + +Route::get('/foo/1', function () { + return 'GET response'; +}); diff --git a/tests/Integration/Routing/Fixtures/wildcard_catch_all_routes.php b/tests/Integration/Routing/Fixtures/wildcard_catch_all_routes.php new file mode 100644 index 0000000000000000000000000000000000000000..df06a5af1e3ed418bc64c8e50e9a31f3de93535a --- /dev/null +++ b/tests/Integration/Routing/Fixtures/wildcard_catch_all_routes.php @@ -0,0 +1,11 @@ +<?php + +use Illuminate\Support\Facades\Route; + +Route::get('/foo', function () { + return 'Regular route'; +}); + +Route::get('{slug}', function () { + return 'Wildcard route'; +}); diff --git a/tests/Integration/Routing/FluentRoutingTest.php b/tests/Integration/Routing/FluentRoutingTest.php index 16bc0b7500749a5bf9ffa3b966b65b718ca01121..3d9871e15365b7de3ad5dd3000702b08fab8421a 100644 --- a/tests/Integration/Routing/FluentRoutingTest.php +++ b/tests/Integration/Routing/FluentRoutingTest.php @@ -5,35 +5,47 @@ namespace Illuminate\Tests\Integration\Routing; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class FluentRoutingTest extends TestCase { + public static $value = ''; + public function testMiddlewareRunWhenRegisteredAsArrayOrParams() { + $controller = function () { + return 'Hello World'; + }; + Route::middleware(Middleware::class, Middleware2::class) - ->get('one', function () { - return 'Hello World'; - }); + ->get('before', $controller); - Route::get('two', function () { - return 'Hello World'; - })->middleware(Middleware::class, Middleware2::class); + Route::get('after', $controller) + ->middleware(Middleware::class, Middleware2::class); Route::middleware([Middleware::class, Middleware2::class]) - ->get('three', function () { - return 'Hello World'; - }); + ->get('before_array', $controller); - Route::get('four', function () { - return 'Hello World'; - })->middleware([Middleware::class, Middleware2::class]); + Route::get('after_array', $controller) + ->middleware([Middleware::class, Middleware2::class]); - $this->assertSame('middleware output', $this->get('one')->content()); - $this->assertSame('middleware output', $this->get('two')->content()); - $this->assertSame('middleware output', $this->get('three')->content()); - $this->assertSame('middleware output', $this->get('four')->content()); + Route::middleware(Middleware::class) + ->get('before_after', $controller) + ->middleware([Middleware2::class]); + + Route::middleware(Middleware::class) + ->middleware(Middleware2::class) + ->get('both_before', $controller); + + Route::get('both_after', $controller) + ->middleware(Middleware::class) + ->middleware(Middleware2::class); + + $this->assertSame('1_2', $this->get('before')->content()); + $this->assertSame('1_2', $this->get('after')->content()); + $this->assertSame('1_2', $this->get('before_array')->content()); + $this->assertSame('1_2', $this->get('after_array')->content()); + $this->assertSame('1_2', $this->get('before_after')->content()); + $this->assertSame('1_2', $this->get('both_before')->content()); + $this->assertSame('1_2', $this->get('both_after')->content()); } } @@ -41,6 +53,8 @@ class Middleware { public function handle($request, $next) { + FluentRoutingTest::$value = '1'; + return $next($request); } } @@ -49,6 +63,6 @@ class Middleware2 { public function handle() { - return 'middleware output'; + return FluentRoutingTest::$value.'_2'; } } diff --git a/tests/Integration/Routing/ImplicitRouteBindingTest.php b/tests/Integration/Routing/ImplicitRouteBindingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..56311b55975b3215f5576e2f1b9f16504408cbb7 --- /dev/null +++ b/tests/Integration/Routing/ImplicitRouteBindingTest.php @@ -0,0 +1,246 @@ +<?php + +namespace Illuminate\Tests\Integration\Routing; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Schema; +use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles; +use Orchestra\Testbench\TestCase; + +class ImplicitRouteBindingTest extends TestCase +{ + use InteractsWithPublishedFiles; + + protected $files = [ + 'routes/testbench.php', + ]; + + protected function tearDown(): void + { + $this->tearDownInteractsWithPublishedFiles(); + + parent::tearDown(); + } + + protected function defineDatabaseMigrations(): void + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_id'); + $table->timestamps(); + }); + + $this->beforeApplicationDestroyed(function () { + Schema::dropIfExists('users'); + Schema::dropIfExists('posts'); + }); + } + + public function testWithRouteCachingEnabled() + { + $this->defineCacheRoutes(<<<PHP +<?php + +use Illuminate\Tests\Integration\Routing\ImplicitBindingUser; + +Route::post('/user/{user}', function (ImplicitBindingUser \$user) { + return \$user; +})->middleware('web'); +PHP); + + $user = ImplicitBindingUser::create(['name' => 'Dries']); + + $response = $this->postJson("/user/{$user->id}"); + + $response->assertJson([ + 'id' => $user->id, + 'name' => $user->name, + ]); + } + + public function testWithoutRouteCachingEnabled() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + + config(['app.key' => str_repeat('a', 32)]); + + Route::post('/user/{user}', function (ImplicitBindingUser $user) { + return $user; + })->middleware(['web']); + + $response = $this->postJson("/user/{$user->id}"); + + $response->assertJson([ + 'id' => $user->id, + 'name' => $user->name, + ]); + } + + public function testSoftDeletedModelsAreNotRetrieved() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + + $user->delete(); + + config(['app.key' => str_repeat('a', 32)]); + + Route::post('/user/{user}', function (ImplicitBindingUser $user) { + return $user; + })->middleware(['web']); + + $response = $this->postJson("/user/{$user->id}"); + + $response->assertStatus(404); + } + + public function testSoftDeletedModelsCanBeRetrievedUsingWithTrashedMethod() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + + $user->delete(); + + config(['app.key' => str_repeat('a', 32)]); + + Route::post('/user/{user}', function (ImplicitBindingUser $user) { + return $user; + })->middleware(['web'])->withTrashed(); + + $response = $this->postJson("/user/{$user->id}"); + + $response->assertJson([ + 'id' => $user->id, + 'name' => $user->name, + ]); + } + + public function testEnforceScopingImplicitRouteBindings() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + $post = ImplicitBindingPost::create(['user_id' => 2]); + $this->assertEmpty($user->posts); + + config(['app.key' => str_repeat('a', 32)]); + + Route::scopeBindings()->group(function () { + Route::get('/user/{user}/post/{post}', function (ImplicitBindingUser $user, ImplicitBindingPost $post) { + return [$user, $post]; + })->middleware(['web']); + }); + + $response = $this->getJson("/user/{$user->id}/post/{$post->id}"); + + $response->assertNotFound(); + } + + public function testEnforceScopingImplicitRouteBindingsWithTrashedAndChildWithNoSoftDeleteTrait() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + + $post = $user->posts()->create(); + + $user->delete(); + + config(['app.key' => str_repeat('a', 32)]); + Route::scopeBindings()->group(function () { + Route::get('/user/{user}/post/{post}', function (ImplicitBindingUser $user, ImplicitBindingPost $post) { + return [$user, $post]; + })->middleware(['web'])->withTrashed(); + }); + + $response = $this->getJson("/user/{$user->id}/post/{$post->id}"); + $response->assertOk(); + $response->assertJson([ + [ + 'id' => $user->id, + 'name' => $user->name, + ], + [ + 'id' => 1, + 'user_id' => 1, + ], + ]); + } + + public function testEnforceScopingImplicitRouteBindingsWithRouteCachingEnabled() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + $post = ImplicitBindingPost::create(['user_id' => 2]); + $this->assertEmpty($user->posts); + + $this->defineCacheRoutes(<<<PHP +<?php + +use Illuminate\Tests\Integration\Routing\ImplicitBindingUser; +use Illuminate\Tests\Integration\Routing\ImplicitBindingPost; + +Route::group(['scoping' => true], function () { + Route::get('/user/{user}/post/{post}', function (ImplicitBindingUser \$user, ImplicitBindingPost \$post) { + return [\$user, \$post]; + })->middleware(['web']); +}); +PHP); + + $response = $this->getJson("/user/{$user->id}/post/{$post->id}"); + + $response->assertNotFound(); + } + + public function testWithoutEnforceScopingImplicitRouteBindings() + { + $user = ImplicitBindingUser::create(['name' => 'Dries']); + $post = ImplicitBindingPost::create(['user_id' => 2]); + $this->assertEmpty($user->posts); + + config(['app.key' => str_repeat('a', 32)]); + + Route::group(['scoping' => false], function () { + Route::get('/user/{user}/post/{post}', function (ImplicitBindingUser $user, ImplicitBindingPost $post) { + return [$user, $post]; + })->middleware(['web']); + }); + + $response = $this->getJson("/user/{$user->id}/post/{$post->id}"); + $response->assertOk(); + $response->assertJson([ + [ + 'id' => $user->id, + 'name' => $user->name, + ], + [ + 'id' => 1, + 'user_id' => 2, + ], + ]); + } +} + +class ImplicitBindingUser extends Model +{ + use SoftDeletes; + + public $table = 'users'; + + protected $fillable = ['name']; + + public function posts() + { + return $this->hasMany(ImplicitBindingPost::class, 'user_id'); + } +} + +class ImplicitBindingPost extends Model +{ + public $table = 'posts'; + + protected $fillable = ['user_id']; +} diff --git a/tests/Integration/Routing/ResponsableTest.php b/tests/Integration/Routing/ResponsableTest.php index 6c247eb3a2128290235d2815d6b37bb2e5ef1723..e900a94d1093804e204a94efccb562447b72c53c 100644 --- a/tests/Integration/Routing/ResponsableTest.php +++ b/tests/Integration/Routing/ResponsableTest.php @@ -6,9 +6,6 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class ResponsableTest extends TestCase { public function testResponsableObjectsAreRendered() diff --git a/tests/Integration/Routing/RouteApiResourceTest.php b/tests/Integration/Routing/RouteApiResourceTest.php index bc63e00b9f9fa3137e92ce84713b733135ab8ccd..93e2c08c24148a706568fd760c96f4f772710832 100644 --- a/tests/Integration/Routing/RouteApiResourceTest.php +++ b/tests/Integration/Routing/RouteApiResourceTest.php @@ -7,9 +7,6 @@ use Illuminate\Tests\Integration\Routing\Fixtures\ApiResourceTaskController; use Illuminate\Tests\Integration\Routing\Fixtures\ApiResourceTestController; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RouteApiResourceTest extends TestCase { public function testApiResource() diff --git a/tests/Integration/Routing/RouteCachingTest.php b/tests/Integration/Routing/RouteCachingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1d070772854c4ef0a304acdf29bd02dcd0335572 --- /dev/null +++ b/tests/Integration/Routing/RouteCachingTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Tests\Integration\Routing; + +use Orchestra\Testbench\TestCase; + +class RouteCachingTest extends TestCase +{ + public function testWildcardCatchAllRoutes() + { + $this->routes(__DIR__.'/Fixtures/wildcard_catch_all_routes.php'); + + $this->get('/foo')->assertSee('Regular route'); + $this->get('/bar')->assertSee('Wildcard route'); + } + + public function testRedirectRoutes() + { + $this->routes(__DIR__.'/Fixtures/redirect_routes.php'); + + $this->post('/foo/1')->assertRedirect('/foo/1/bar'); + $this->get('/foo/1/bar')->assertSee('Redirect response'); + $this->get('/foo/1')->assertRedirect('/foo/1/bar'); + } + + protected function routes(string $file) + { + $this->defineCacheRoutes(file_get_contents($file)); + } +} diff --git a/tests/Integration/Routing/RouteRedirectTest.php b/tests/Integration/Routing/RouteRedirectTest.php index 558525c27761472582a5a6e957921dec3944a4ca..15715fad6706dcf4af3ac6787b208cb63ecbbf97 100644 --- a/tests/Integration/Routing/RouteRedirectTest.php +++ b/tests/Integration/Routing/RouteRedirectTest.php @@ -5,9 +5,6 @@ namespace Illuminate\Tests\Integration\Routing; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RouteRedirectTest extends TestCase { /** @@ -31,7 +28,6 @@ class RouteRedirectTest extends TestCase 'route redirect with two parameters' => ['from/{param}/{param2?}', 'to', '/from/value1/value2', '/to'], 'route redirect with one parameter replacement' => ['users/{user}/repos', 'members/{user}/repos', '/users/22/repos', '/members/22/repos'], 'route redirect with two parameter replacements' => ['users/{user}/repos/{repo}', 'members/{user}/projects/{repo}', '/users/22/repos/laravel-framework', '/members/22/projects/laravel-framework'], - 'route redirect with two parameter replacements' => ['users/{user}/repos/{repo}', 'members/{user}/projects/{repo}', '/users/22/repos/laravel-framework', '/members/22/projects/laravel-framework'], 'route redirect with non existent optional parameter replacements' => ['users/{user?}', 'members/{user?}', '/users', '/members'], 'route redirect with existing parameter replacements' => ['users/{user?}', 'members/{user?}', '/users/22', '/members/22'], 'route redirect with two optional replacements' => ['users/{user?}/{repo?}', 'members/{user?}', '/users/22', '/members/22'], diff --git a/tests/Integration/Routing/RouteViewTest.php b/tests/Integration/Routing/RouteViewTest.php index 82d28ec55e0c5afe26237432a6748f56d494b9ca..758913e79bfd48240f12bbdd3cebe2729a151730 100644 --- a/tests/Integration/Routing/RouteViewTest.php +++ b/tests/Integration/Routing/RouteViewTest.php @@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\View; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RouteViewTest extends TestCase { public function testRouteView() @@ -18,6 +15,7 @@ class RouteViewTest extends TestCase View::addLocation(__DIR__.'/Fixtures'); $this->assertStringContainsString('Test bar', $this->get('/route')->getContent()); + $this->assertSame(200, $this->get('/route')->status()); } public function testRouteViewWithParams() @@ -29,4 +27,31 @@ class RouteViewTest extends TestCase $this->assertStringContainsString('Test bar', $this->get('/route/value1/value2')->getContent()); $this->assertStringContainsString('Test bar', $this->get('/route/value1')->getContent()); } + + public function testRouteViewWithStatus() + { + Route::view('route', 'view', ['foo' => 'bar'], 418); + + View::addLocation(__DIR__.'/Fixtures'); + + $this->assertSame(418, $this->get('/route')->status()); + } + + public function testRouteViewWithHeaders() + { + Route::view('route', 'view', ['foo' => 'bar'], 418, ['Framework' => 'Laravel']); + + View::addLocation(__DIR__.'/Fixtures'); + + $this->assertSame('Laravel', $this->get('/route')->headers->get('Framework')); + } + + public function testRouteViewOverloadingStatusWithHeaders() + { + Route::view('route', 'view', ['foo' => 'bar'], ['Framework' => 'Laravel']); + + View::addLocation(__DIR__.'/Fixtures'); + + $this->assertSame('Laravel', $this->get('/route')->headers->get('Framework')); + } } diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/Routing/SimpleRouteTest.php similarity index 75% rename from tests/Integration/IntegrationTest.php rename to tests/Integration/Routing/SimpleRouteTest.php index ae781114653013bfeb66dde675c9606d65df6c0a..1515140852bcb04f70c11a61f833273951bf8b53 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/Routing/SimpleRouteTest.php @@ -1,14 +1,11 @@ <?php -namespace Illuminate\Tests\Integration; +namespace Illuminate\Tests\Integration\Routing; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ -class IntegrationTest extends TestCase +class SimpleRouteTest extends TestCase { public function testSimpleRouteThroughTheFramework() { diff --git a/tests/Integration/Routing/UrlSigningTest.php b/tests/Integration/Routing/UrlSigningTest.php index 730006611a70bee648e21b133e7e5f6c58a89d8a..491d7f6068f1d5de49881797d26617b3fa4886b4 100644 --- a/tests/Integration/Routing/UrlSigningTest.php +++ b/tests/Integration/Routing/UrlSigningTest.php @@ -8,11 +8,9 @@ use Illuminate\Routing\Middleware\ValidateSignature; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; +use InvalidArgumentException; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class UrlSigningTest extends TestCase { public function testSigningUrl() @@ -25,6 +23,20 @@ class UrlSigningTest extends TestCase $this->assertSame('valid', $this->get($url)->original); } + public function testSigningUrlWithCustomRouteSlug() + { + Route::get('/foo/{post:slug}', function (Request $request, $slug) { + return ['slug' => $slug, 'valid' => $request->hasValidSignature() ? 'valid' : 'invalid']; + })->name('foo'); + + $model = new RoutableInterfaceStub; + $model->routable = 'routable-slug'; + + $this->assertIsString($url = URL::signedRoute('foo', ['post' => $model])); + $this->assertSame('valid', $this->get($url)->original['valid']); + $this->assertSame('routable-slug', $this->get($url)->original['slug']); + } + public function testTemporarySignedUrls() { Route::get('/foo/{id}', function (Request $request, $id) { @@ -39,6 +51,18 @@ class UrlSigningTest extends TestCase $this->assertSame('invalid', $this->get($url)->original); } + public function testTemporarySignedUrlsWithExpiresParameter() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('reserved'); + + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + URL::temporarySignedRoute('foo', now()->addMinutes(5), ['id' => 1, 'expires' => 253402300799]); + } + public function testSignedUrlWithUrlWithoutSignatureParameter() { Route::get('/foo/{id}', function (Request $request, $id) { @@ -48,6 +72,76 @@ class UrlSigningTest extends TestCase $this->assertSame('invalid', $this->get('/foo/1')->original); } + public function testSignedUrlWithNullParameter() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1, 'param'])); + $this->assertSame('valid', $this->get($url)->original); + } + + public function testSignedUrlWithEmptyStringParameter() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1, 'param' => ''])); + $this->assertSame('valid', $this->get($url)->original); + } + + public function testSignedUrlWithMultipleParameters() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1, 'param1' => 'value1', 'param2' => 'value2'])); + $this->assertSame('valid', $this->get($url)->original); + } + + public function testSignedUrlWithSignatureTextInKeyOrValue() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1, 'custom-signature' => 'signature=value'])); + $this->assertSame('valid', $this->get($url)->original); + } + + public function testSignedUrlWithAppendedNullParameterInvalid() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1])); + $this->assertSame('invalid', $this->get($url.'&appended')->original); + } + + public function testSignedUrlParametersParsedCorrectly() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() + && intval($id) === 1 + && $request->has('paramEmpty') + && $request->has('paramEmptyString') + && $request->query('paramWithValue') === 'value' + ? 'valid' + : 'invalid'; + })->name('foo'); + + $this->assertIsString($url = URL::signedRoute('foo', ['id' => 1, + 'paramEmpty', + 'paramEmptyString' => '', + 'paramWithValue' => 'value', + ])); + $this->assertSame('valid', $this->get($url)->original); + } + public function testSignedMiddleware() { Route::get('/foo/{id}', function (Request $request, $id) { @@ -85,11 +179,25 @@ class UrlSigningTest extends TestCase $this->assertIsString($url = URL::signedRoute('foo', $model)); $this->assertSame('routable', $this->get($url)->original); } + + public function testSignedMiddlewareWithRelativePath() + { + Route::get('/foo/relative', function (Request $request) { + return $request->hasValidSignature($absolute = false) ? 'valid' : 'invalid'; + })->name('foo')->middleware('signed:relative'); + + $this->assertIsString($url = 'https://fake.test'.URL::signedRoute('foo', [], null, $absolute = false)); + $this->assertSame('valid', $this->get($url)->original); + + $response = $this->get('/foo/relative'); + $response->assertStatus(403); + } } class RoutableInterfaceStub implements UrlRoutable { public $key; + public $slug = 'routable-slug'; public function getRouteKey() { @@ -101,7 +209,12 @@ class RoutableInterfaceStub implements UrlRoutable return 'routable'; } - public function resolveRouteBinding($routeKey) + public function resolveRouteBinding($routeKey, $field = null) + { + // + } + + public function resolveChildRouteBinding($childType, $routeKey, $field = null) { // } diff --git a/tests/Integration/Session/SessionPersistenceTest.php b/tests/Integration/Session/SessionPersistenceTest.php index 6556b867caebfc93f70239be865f33efb1da04ef..4862bbedd0b7492f35657961684f093cdb12c164 100644 --- a/tests/Integration/Session/SessionPersistenceTest.php +++ b/tests/Integration/Session/SessionPersistenceTest.php @@ -12,9 +12,6 @@ use Illuminate\Support\Str; use Mockery; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class SessionPersistenceTest extends TestCase { public function testSessionIsPersistedEvenIfExceptionIsThrownFromRoute() diff --git a/tests/Integration/Support/AuthFacadeTest.php b/tests/Integration/Support/AuthFacadeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..94deebcf207ab9125a08f92c6d7be6328d15c2aa --- /dev/null +++ b/tests/Integration/Support/AuthFacadeTest.php @@ -0,0 +1,19 @@ +<?php + +namespace Illuminate\Tests\Integration\Support; + +use Illuminate\Support\Facades\Auth; +use Orchestra\Testbench\TestCase; +use RuntimeException; + +class AuthFacadeTest extends TestCase +{ + public function testItFailsIfTheUiPackageIsMissing() + { + $this->expectExceptionObject(new RuntimeException( + 'In order to use the Auth::routes() method, please install the laravel/ui package.' + )); + + Auth::routes(); + } +} diff --git a/tests/Integration/Support/Fixtures/MultipleInstanceManager.php b/tests/Integration/Support/Fixtures/MultipleInstanceManager.php new file mode 100644 index 0000000000000000000000000000000000000000..9c1500dc3b04d1e0e467ec664f32ae97fb62329e --- /dev/null +++ b/tests/Integration/Support/Fixtures/MultipleInstanceManager.php @@ -0,0 +1,81 @@ +<?php + +namespace Illuminate\Tests\Integration\Support\Fixtures; + +use Illuminate\Support\MultipleInstanceManager as BaseMultipleInstanceManager; + +class MultipleInstanceManager extends BaseMultipleInstanceManager +{ + protected $defaultInstance = 'foo'; + + protected function createFooDriver(array $config) + { + return new class($config) + { + public $config; + + public function __construct($config) + { + $this->config = $config; + } + }; + } + + protected function createBarDriver(array $config) + { + return new class($config) + { + public $config; + + public function __construct($config) + { + $this->config = $config; + } + }; + } + + /** + * Get the default instance name. + * + * @return string + */ + public function getDefaultInstance() + { + return $this->defaultInstance; + } + + /** + * Set the default instance name. + * + * @param string $name + * @return void + */ + public function setDefaultInstance($name) + { + $this->defaultInstance = $name; + } + + /** + * Get the instance specific configuration. + * + * @param string $name + * @return array + */ + public function getInstanceConfig($name) + { + switch ($name) { + case 'foo': + return [ + 'driver' => 'foo', + 'foo-option' => 'option-value', + ]; + case 'bar': + return [ + 'driver' => 'bar', + 'bar-option' => 'option-value', + ]; + default: + return []; + } + } +} diff --git a/tests/Integration/Support/MultipleInstanceManagerTest.php b/tests/Integration/Support/MultipleInstanceManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ccdf2544f2f6bca16daf1286da99fff3c3324e61 --- /dev/null +++ b/tests/Integration/Support/MultipleInstanceManagerTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Illuminate\Tests\Integration\Support; + +use Illuminate\Tests\Integration\Support\Fixtures\MultipleInstanceManager; +use Orchestra\Testbench\TestCase; + +class MultipleInstanceManagerTest extends TestCase +{ + public function test_configurable_instances_can_be_resolved() + { + $manager = new MultipleInstanceManager($this->app); + + $fooInstance = $manager->instance('foo'); + $this->assertEquals('option-value', $fooInstance->config['foo-option']); + + $barInstance = $manager->instance('bar'); + $this->assertEquals('option-value', $barInstance->config['bar-option']); + + $duplicateFooInstance = $manager->instance('foo'); + $duplicateBarInstance = $manager->instance('bar'); + $this->assertEquals(spl_object_hash($fooInstance), spl_object_hash($duplicateFooInstance)); + $this->assertEquals(spl_object_hash($barInstance), spl_object_hash($duplicateBarInstance)); + } + + public function test_unresolvable_isntances_throw_errors() + { + $this->expectException(\RuntimeException::class); + + $manager = new MultipleInstanceManager($this->app); + + $instance = $manager->instance('missing'); + } +} diff --git a/tests/Integration/Testing/ArtisanCommandTest.php b/tests/Integration/Testing/ArtisanCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..82e8277325973b7e47e8f9519f14bbdc3a458151 --- /dev/null +++ b/tests/Integration/Testing/ArtisanCommandTest.php @@ -0,0 +1,132 @@ +<?php + +namespace Illuminate\Tests\Integration\Testing; + +use Illuminate\Support\Facades\Artisan; +use Mockery; +use Mockery\Exception\InvalidCountException; +use Mockery\Exception\InvalidOrderException; +use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\AssertionFailedError; + +class ArtisanCommandTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Artisan::command('survey', function () { + $name = $this->ask('What is your name?'); + + $language = $this->choice('Which language do you prefer?', [ + 'PHP', + 'Ruby', + 'Python', + ]); + + $this->line("Your name is $name and you prefer $language."); + }); + + Artisan::command('slim', function () { + $this->line($this->ask('Who?')); + $this->line($this->ask('What?')); + $this->line($this->ask('Huh?')); + }); + } + + public function test_console_command_that_passes() + { + $this->artisan('survey') + ->expectsQuestion('What is your name?', 'Taylor Otwell') + ->expectsQuestion('Which language do you prefer?', 'PHP') + ->expectsOutput('Your name is Taylor Otwell and you prefer PHP.') + ->doesntExpectOutput('Your name is Taylor Otwell and you prefer Ruby.') + ->assertExitCode(0); + } + + public function test_console_command_that_passes_with_repeating_output() + { + $this->artisan('slim') + ->expectsQuestion('Who?', 'Taylor') + ->expectsQuestion('What?', 'Taylor') + ->expectsQuestion('Huh?', 'Taylor') + ->expectsOutput('Taylor') + ->doesntExpectOutput('Otwell') + ->expectsOutput('Taylor') + ->expectsOutput('Taylor') + ->assertExitCode(0); + } + + public function test_console_command_that_fails_from_unexpected_output() + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "Your name is Taylor Otwell and you prefer PHP." was printed.'); + + $this->artisan('survey') + ->expectsQuestion('What is your name?', 'Taylor Otwell') + ->expectsQuestion('Which language do you prefer?', 'PHP') + ->doesntExpectOutput('Your name is Taylor Otwell and you prefer PHP.') + ->assertExitCode(0); + } + + public function test_console_command_that_fails_from_missing_output() + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "Your name is Taylor Otwell and you prefer PHP." was not printed.'); + + $this->ignoringMockOnceExceptions(function () { + $this->artisan('survey') + ->expectsQuestion('What is your name?', 'Taylor Otwell') + ->expectsQuestion('Which language do you prefer?', 'Ruby') + ->expectsOutput('Your name is Taylor Otwell and you prefer PHP.') + ->assertExitCode(0); + }); + } + + public function test_console_command_that_fails_from_exit_code_mismatch() + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Expected status code 1 but received 0.'); + + $this->artisan('survey') + ->expectsQuestion('What is your name?', 'Taylor Otwell') + ->expectsQuestion('Which language do you prefer?', 'PHP') + ->assertExitCode(1); + } + + public function test_console_command_that_fails_from_unordered_output() + { + $this->expectException(InvalidOrderException::class); + + $this->ignoringMockOnceExceptions(function () { + $this->artisan('slim') + ->expectsQuestion('Who?', 'Taylor') + ->expectsQuestion('What?', 'Danger') + ->expectsQuestion('Huh?', 'Otwell') + ->expectsOutput('Taylor') + ->expectsOutput('Otwell') + ->expectsOutput('Danger') + ->assertExitCode(0); + }); + } + + /** + * Don't allow Mockery's InvalidCountException to be reported. Mocks setup + * in PendingCommand cause PHPUnit tearDown() to later throw the exception. + * + * @param callable $callback + * @return void + */ + protected function ignoringMockOnceExceptions(callable $callback) + { + try { + $callback(); + } finally { + try { + Mockery::close(); + } catch (InvalidCountException $e) { + // Ignore mock exception from PendingCommand::expectsOutput(). + } + } + } +} diff --git a/tests/Integration/Validation/RequestValidationTest.php b/tests/Integration/Validation/RequestValidationTest.php index 15688d04a34edcb5a940677fb48f50f300abc476..5f7e42b748aad2d1f1313912cbdd61cb09271f01 100644 --- a/tests/Integration/Validation/RequestValidationTest.php +++ b/tests/Integration/Validation/RequestValidationTest.php @@ -6,9 +6,6 @@ use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Orchestra\Testbench\TestCase; -/** - * @group integration - */ class RequestValidationTest extends TestCase { public function testValidateMacro() @@ -45,7 +42,7 @@ class RequestValidationTest extends TestCase try { $request->validateWithBag('some_bag', ['name' => 'string']); } catch (ValidationException $validationException) { - $this->assertEquals('some_bag', $validationException->errorBag); + $this->assertSame('some_bag', $validationException->errorBag); } } } diff --git a/tests/Integration/Validation/ValidatorTest.php b/tests/Integration/Validation/ValidatorTest.php index 7dd3c8dd8146690d48f85180e8b02ddc6abbf307..3dd82a22b0aa4a581f27b0c6487024ac07ca96cd 100644 --- a/tests/Integration/Validation/ValidatorTest.php +++ b/tests/Integration/Validation/ValidatorTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Integration\Validation; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; use Illuminate\Translation\ArrayLoader; use Illuminate\Translation\Translator; @@ -19,19 +20,56 @@ class ValidatorTest extends DatabaseTestCase Schema::create('users', function (Blueprint $table) { $table->increments('id'); + $table->string('uuid'); $table->string('first_name'); }); - User::create(['first_name' => 'John']); - User::create(['first_name' => 'John']); + User::create(['uuid' => (string) Str::uuid(), 'first_name' => 'John']); + User::create(['uuid' => (string) Str::uuid(), 'first_name' => 'Jim']); } public function testExists() { - $validator = $this->getValidator(['first_name' => ['John', 'Jim']], ['first_name' => 'exists:users']); + $validator = $this->getValidator(['first_name' => ['John', 'Taylor']], ['first_name' => 'exists:users']); $this->assertFalse($validator->passes()); } + public function testUnique() + { + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:'.User::class]); + $this->assertFalse($validator->passes()); + + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:'.User::class.',first_name,1']); + $this->assertTrue($validator->passes()); + + $validator = $this->getValidator(['first_name' => 'Taylor'], ['first_name' => 'unique:'.User::class]); + $this->assertTrue($validator->passes()); + } + + public function testUniqueWithCustomModelKey() + { + $_SERVER['CUSTOM_MODEL_KEY_NAME'] = 'uuid'; + + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:'.UserWithUuid::class]); + $this->assertFalse($validator->passes()); + + $user = UserWithUuid::where('first_name', 'John')->first(); + + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:'.UserWithUuid::class.',first_name,'.$user->uuid]); + $this->assertTrue($validator->passes()); + + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:users,first_name,'.$user->uuid.',uuid']); + $this->assertTrue($validator->passes()); + + $validator = $this->getValidator(['first_name' => 'John'], ['first_name' => 'unique:users,first_name,'.$user->uuid.',id']); + $this->assertFalse($validator->passes()); + + $validator = $this->getValidator(['first_name' => 'Taylor'], ['first_name' => 'unique:'.UserWithUuid::class]); + $this->assertTrue($validator->passes()); + + unset($_SERVER['CUSTOM_MODEL_KEY_NAME']); + } + public function testImplicitAttributeFormatting() { $translator = new Translator(new ArrayLoader, 'en'); @@ -46,7 +84,7 @@ class ValidatorTest extends DatabaseTestCase $validator->passes(); - $this->assertEquals('name at line 1 must be a string!', $validator->getMessageBag()->all()[0]); + $this->assertSame('name at line 1 must be a string!', $validator->getMessageBag()->all()[0]); } protected function getValidator(array $data, array $rules) @@ -62,5 +100,19 @@ class ValidatorTest extends DatabaseTestCase class User extends Model { public $timestamps = false; - protected $guarded = ['id']; + protected $guarded = []; +} + +class UserWithUuid extends Model +{ + protected $table = 'users'; + public $timestamps = false; + protected $guarded = []; + protected $keyType = 'string'; + public $incrementing = false; + + public function getKeyName() + { + return 'uuid'; + } } diff --git a/tests/Integration/View/BladeTest.php b/tests/Integration/View/BladeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b3d8f51eedc7c787f0c1c70bd769ed0b30a63735 --- /dev/null +++ b/tests/Integration/View/BladeTest.php @@ -0,0 +1,144 @@ +<?php + +namespace Illuminate\Tests\Integration\View; + +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\View; +use Illuminate\View\Component; +use Orchestra\Testbench\TestCase; + +class BladeTest extends TestCase +{ + public function test_rendering_blade_string() + { + $this->assertSame('Hello Taylor', Blade::render('Hello {{ $name }}', ['name' => 'Taylor'])); + } + + public function test_rendering_blade_long_maxpathlen_string() + { + $longString = str_repeat('a', PHP_MAXPATHLEN); + + $result = Blade::render($longString.'{{ $name }}', ['name' => 'a']); + + $this->assertSame($longString.'a', $result); + } + + public function test_rendering_blade_component_instance() + { + $component = new HelloComponent('Taylor'); + + $this->assertSame('Hello Taylor', Blade::renderComponent($component)); + } + + public function test_basic_blade_rendering() + { + $view = View::make('hello', ['name' => 'Taylor'])->render(); + + $this->assertSame('Hello Taylor', trim($view)); + } + + public function test_rendering_a_component() + { + $view = View::make('uses-panel', ['name' => 'Taylor'])->render(); + + $this->assertSame('<div class="ml-2"> + Hello Taylor +</div>', trim($view)); + } + + public function test_rendering_a_dynamic_component() + { + $view = View::make('uses-panel-dynamically', ['name' => 'Taylor'])->render(); + + $this->assertSame('<div class="ml-2" wire:model="foo" wire:model.lazy="bar"> + Hello Taylor +</div>', trim($view)); + } + + public function test_rendering_the_same_dynamic_component_with_different_attributes() + { + $view = View::make('varied-dynamic-calls')->render(); + + $this->assertSame('<span class="text-medium"> + Hello Taylor +</span> +<span > + Hello Samuel +</span>', trim($view)); + } + + public function test_inline_link_type_attributes_dont_add_extra_spacing_at_end() + { + $view = View::make('uses-link')->render(); + + $this->assertSame('This is a sentence with a <a href="https://laravel.com">link</a>.', trim($view)); + } + + public function test_appendable_attributes() + { + $view = View::make('uses-appendable-panel', ['name' => 'Taylor', 'withInjectedValue' => true])->render(); + + $this->assertSame('<div class="mt-4 bg-gray-100" data-controller="inside-controller outside-controller" foo="bar"> + Hello Taylor +</div>', trim($view)); + + $view = View::make('uses-appendable-panel', ['name' => 'Taylor', 'withInjectedValue' => false])->render(); + + $this->assertSame('<div class="mt-4 bg-gray-100" data-controller="inside-controller" foo="bar"> + Hello Taylor +</div>', trim($view)); + } + + public function tested_nested_anonymous_attribute_proxying_works_correctly() + { + $view = View::make('uses-child-input')->render(); + + $this->assertSame('<input class="disabled-class" foo="bar" type="text" disabled />', trim($view)); + } + + public function test_consume_defaults() + { + $view = View::make('consume')->render(); + + $this->assertSame('<h1>Menu</h1> +<div>Slot: A, Color: orange, Default: foo</div> +<div>Slot: B, Color: red, Default: foo</div> +<div>Slot: C, Color: blue, Default: foo</div> +<div>Slot: D, Color: red, Default: foo</div> +<div>Slot: E, Color: red, Default: foo</div> +<div>Slot: F, Color: yellow, Default: foo</div>', trim($view)); + } + + public function test_consume_with_props() + { + $view = View::make('consume', ['color' => 'rebeccapurple'])->render(); + + $this->assertSame('<h1>Menu</h1> +<div>Slot: A, Color: orange, Default: foo</div> +<div>Slot: B, Color: rebeccapurple, Default: foo</div> +<div>Slot: C, Color: blue, Default: foo</div> +<div>Slot: D, Color: rebeccapurple, Default: foo</div> +<div>Slot: E, Color: rebeccapurple, Default: foo</div> +<div>Slot: F, Color: yellow, Default: foo</div>', trim($view)); + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('view.paths', [__DIR__.'/templates']); + } +} + +class HelloComponent extends Component +{ + public $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + public function render() + { + return 'Hello {{ $name }}'; + } +} diff --git a/tests/Integration/View/RenderableViewExceptionTest.php b/tests/Integration/View/RenderableViewExceptionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..93c91cb31387e403ffcc1419885afc11888145a4 --- /dev/null +++ b/tests/Integration/View/RenderableViewExceptionTest.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Tests\Integration\View; + +use Exception; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\View; +use Orchestra\Testbench\TestCase; + +class RenderableViewExceptionTest extends TestCase +{ + public function testRenderMethodOfExceptionThrownInViewGetsHandled() + { + Route::get('/', function () { + return View::make('renderable-exception'); + }); + + $response = $this->get('/'); + + $response->assertSee('This is a renderable exception.'); + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('view.paths', [__DIR__.'/templates']); + } +} + +class RenderableException extends Exception +{ + public function render($request) + { + return new Response('This is a renderable exception.'); + } +} diff --git a/tests/Integration/View/templates/components/appendable-panel.blade.php b/tests/Integration/View/templates/components/appendable-panel.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..d3c86f05674895316687692e954fe8ef3b355dfd --- /dev/null +++ b/tests/Integration/View/templates/components/appendable-panel.blade.php @@ -0,0 +1,5 @@ +@props(['name']) + +<div {{ $attributes->merge(['class' => 'mt-4', 'data-controller' => $attributes->prepends('inside-controller')]) }}> + Hello {{ $name }} +</div> diff --git a/tests/Integration/View/templates/components/base-input.blade.php b/tests/Integration/View/templates/components/base-input.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..4d8f09367c55daefd754fa31e088776b1370af30 --- /dev/null +++ b/tests/Integration/View/templates/components/base-input.blade.php @@ -0,0 +1,11 @@ +@props(['disabled' => false]) + +@php +if ($disabled) { + $class = 'disabled-class'; +} else { + $class = 'not-disabled-class'; +} +@endphp + +<input {{ $attributes->merge(['class' => $class]) }} {{ $disabled ? 'disabled' : '' }} /> \ No newline at end of file diff --git a/tests/Integration/View/templates/components/child-input.blade.php b/tests/Integration/View/templates/components/child-input.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..711e20d4e6131b8b394a95934109672d0e7b8a00 --- /dev/null +++ b/tests/Integration/View/templates/components/child-input.blade.php @@ -0,0 +1 @@ +<x-base-input foo="bar" :attributes="$attributes" /> \ No newline at end of file diff --git a/tests/Integration/View/templates/components/hello-span.blade.php b/tests/Integration/View/templates/components/hello-span.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..fac695a4acbf91e523aa54856c5f065984a43a84 --- /dev/null +++ b/tests/Integration/View/templates/components/hello-span.blade.php @@ -0,0 +1,7 @@ +@props([ + 'name', +]) + +<span {{ $attributes }}> + Hello {{ $name }} +</span> diff --git a/tests/Integration/View/templates/components/link.blade.php b/tests/Integration/View/templates/components/link.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..74b8835393b64a4110584e2ffc3856d62c310fbd --- /dev/null +++ b/tests/Integration/View/templates/components/link.blade.php @@ -0,0 +1,3 @@ +@props(['href']) + +<a href="{{ $href }}">{{ $slot }}</a> \ No newline at end of file diff --git a/tests/Integration/View/templates/components/menu-item.blade.php b/tests/Integration/View/templates/components/menu-item.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..e5b856c2bee37018665db9d766fde386195ec809 --- /dev/null +++ b/tests/Integration/View/templates/components/menu-item.blade.php @@ -0,0 +1,2 @@ +@aware(['color' => 'red', 'default' => 'foo']) +<div>Slot: {{ $slot }}, Color: {{ $color }}, Default: {{ $default }}</div> diff --git a/tests/Integration/View/templates/components/menu.blade.php b/tests/Integration/View/templates/components/menu.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..b79b5b16f545b71f0ed1a3d59c537746a77b9b89 --- /dev/null +++ b/tests/Integration/View/templates/components/menu.blade.php @@ -0,0 +1,6 @@ +<h1>Menu</h1> +<x-menu-item color="orange">A</x-menu-item> +<x-menu-item>B</x-menu-item> +{{ $slot }} +<x-menu-item>E</x-menu-item> +<x-menu-item color="yellow">F</x-menu-item> diff --git a/tests/Integration/View/templates/components/panel.blade.php b/tests/Integration/View/templates/components/panel.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..af78f513986adb1ed6ed6cb1ea303978b95bd703 --- /dev/null +++ b/tests/Integration/View/templates/components/panel.blade.php @@ -0,0 +1,5 @@ +@props(['name']) + +<div {{ $attributes }}> + Hello {{ $name }} +</div> diff --git a/tests/Integration/View/templates/consume.blade.php b/tests/Integration/View/templates/consume.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..ced9b232daca032d10df8b1ee0414e85a551a6e8 --- /dev/null +++ b/tests/Integration/View/templates/consume.blade.php @@ -0,0 +1,15 @@ +@isset($color) + +<x-menu :color="$color"> +<x-menu-item color="blue">C</x-menu-item> +<x-menu-item>D</x-menu-item> +</x-menu> + +@else + +<x-menu> +<x-menu-item color="blue">C</x-menu-item> +<x-menu-item>D</x-menu-item> +</x-menu> + +@endisset diff --git a/tests/Integration/View/templates/hello.blade.php b/tests/Integration/View/templates/hello.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..d209ed35302fed9961dde505f16c7937ba30f5f1 --- /dev/null +++ b/tests/Integration/View/templates/hello.blade.php @@ -0,0 +1 @@ +Hello {{ $name }} diff --git a/tests/Integration/View/templates/renderable-exception.blade.php b/tests/Integration/View/templates/renderable-exception.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..28649eefa7f97becb1d4f64615c399e8c7a5cfd2 --- /dev/null +++ b/tests/Integration/View/templates/renderable-exception.blade.php @@ -0,0 +1,3 @@ +@php + throw new Illuminate\Tests\Integration\View\RenderableException; +@endphp diff --git a/tests/Integration/View/templates/uses-appendable-panel.blade.php b/tests/Integration/View/templates/uses-appendable-panel.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..4e1744d2be9474a24f8ff3515839c08e8cb882a1 --- /dev/null +++ b/tests/Integration/View/templates/uses-appendable-panel.blade.php @@ -0,0 +1,9 @@ +@if ($withInjectedValue) + <x-appendable-panel class="bg-gray-100" :name="$name" data-controller="outside-controller" foo="bar"> + Panel contents + </x-appendable-panel> +@else + <x-appendable-panel class="bg-gray-100" :name="$name" foo="bar"> + Panel contents + </x-appendable-panel> +@endif diff --git a/tests/Integration/View/templates/uses-child-input.blade.php b/tests/Integration/View/templates/uses-child-input.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..bc5bdade5d89076a19fc6476250b353638adccdb --- /dev/null +++ b/tests/Integration/View/templates/uses-child-input.blade.php @@ -0,0 +1 @@ +<x-child-input type="text" :disabled="true" /> \ No newline at end of file diff --git a/tests/Integration/View/templates/uses-link.blade.php b/tests/Integration/View/templates/uses-link.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..53a67cf64e303fa2e964fe0d7a293209346a2b54 --- /dev/null +++ b/tests/Integration/View/templates/uses-link.blade.php @@ -0,0 +1 @@ +This is a sentence with a <x-link href="https://laravel.com">link</x-link>. \ No newline at end of file diff --git a/tests/Integration/View/templates/uses-panel-dynamically.blade.php b/tests/Integration/View/templates/uses-panel-dynamically.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..48cbc3e901921200c5e9e9db1034a592d78a159c --- /dev/null +++ b/tests/Integration/View/templates/uses-panel-dynamically.blade.php @@ -0,0 +1,3 @@ +<x-dynamic-component component="panel" class="ml-2" :name="$name" wire:model="foo" wire:model.lazy="bar"> + Panel contents +</x-dynamic-component> diff --git a/tests/Integration/View/templates/uses-panel.blade.php b/tests/Integration/View/templates/uses-panel.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..aa2f07da36bf3cfa9ea047a4dd22f0bd8777c6cc --- /dev/null +++ b/tests/Integration/View/templates/uses-panel.blade.php @@ -0,0 +1,3 @@ +<x-panel class="ml-2" :name="$name"> + Panel contents +</x-panel> diff --git a/tests/Integration/View/templates/varied-dynamic-calls.blade.php b/tests/Integration/View/templates/varied-dynamic-calls.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..5b267ca4d90666f5a7099f0bb3cbc1c950f5ba46 --- /dev/null +++ b/tests/Integration/View/templates/varied-dynamic-calls.blade.php @@ -0,0 +1,2 @@ +<x-dynamic-component component="hello-span" name="Taylor" class="text-medium" /> +<x-dynamic-component component="hello-span" name="Samuel" /> diff --git a/tests/Log/LogLoggerTest.php b/tests/Log/LogLoggerTest.php index 208bf9e3b8125a1e1a98d7247da57680dc5bbc6d..41163d6d25eed8e1dea2587a95572e7953773159 100755 --- a/tests/Log/LogLoggerTest.php +++ b/tests/Log/LogLoggerTest.php @@ -26,6 +26,27 @@ class LogLoggerTest extends TestCase $writer->error('foo'); } + public function testContextIsAddedToAllSubsequentLogs() + { + $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer->withContext(['bar' => 'baz']); + + $monolog->shouldReceive('error')->once()->with('foo', ['bar' => 'baz']); + + $writer->error('foo'); + } + + public function testContextIsFlushed() + { + $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer->withContext(['bar' => 'baz']); + $writer->withoutContext(); + + $monolog->expects('error')->with('foo', []); + + $writer->error('foo'); + } + public function testLoggerFiresEventsDispatcher() { $writer = new Logger($monolog = m::mock(Monolog::class), $events = new Dispatcher); diff --git a/tests/Log/LogManagerTest.php b/tests/Log/LogManagerTest.php index f365c9f701c041f5168bc88e2207713f5467207d..cd7a3b236d7069525bd99719e6cb4fe8566eb042 100755 --- a/tests/Log/LogManagerTest.php +++ b/tests/Log/LogManagerTest.php @@ -7,14 +7,17 @@ use Illuminate\Log\LogManager; use Monolog\Formatter\HtmlFormatter; use Monolog\Formatter\LineFormatter; use Monolog\Formatter\NormalizerFormatter; +use Monolog\Handler\FingersCrossedHandler; use Monolog\Handler\LogEntriesHandler; use Monolog\Handler\NewRelicHandler; use Monolog\Handler\NullHandler; use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogHandler; use Monolog\Logger as Monolog; +use Monolog\Processor\UidProcessor; use Orchestra\Testbench\TestCase; use ReflectionProperty; +use RuntimeException; class LogManagerTest extends TestCase { @@ -203,6 +206,41 @@ class LogManagerTest extends TestCase } } + public function testItUtilisesTheNullDriverDuringTestsWhenNullDriverUsed() + { + $config = $this->app->make('config'); + $config->set('logging.default', null); + $config->set('logging.channels.null', [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ]); + $manager = new class($this->app) extends LogManager + { + protected function createEmergencyLogger() + { + throw new RuntimeException('Emergency logger was created.'); + } + }; + + // In tests, this should not need to create the emergency logger... + $manager->info('message'); + + // we should also be able to forget the null channel... + $this->assertCount(1, $manager->getChannels()); + $manager->forgetChannel(); + $this->assertCount(0, $manager->getChannels()); + + // However in production we want it to fallback to the emergency logger... + $this->app['env'] = 'production'; + try { + $manager->info('message'); + + $this->fail('Emergency logger was not created as expected.'); + } catch (RuntimeException $exception) { + $this->assertSame('Emergency logger was created.', $exception->getMessage()); + } + } + public function testLogManagerCreateSingleDriverWithConfiguredFormatter() { $config = $this->app['config']; @@ -327,7 +365,7 @@ class LogManagerTest extends TestCase $this->assertSame('Y/m/d--test', $dateFormat->getValue($formatter)); } - public function testLogMnagerPurgeResolvedChannels() + public function testLogManagerPurgeResolvedChannels() { $manager = new LogManager($this->app); @@ -341,4 +379,106 @@ class LogManagerTest extends TestCase $this->assertEmpty($manager->getChannels()); } + + public function testLogManagerCanBuildOnDemandChannel() + { + $manager = new LogManager($this->app); + + $logger = $manager->build([ + 'driver' => 'single', + 'path' => storage_path('logs/on-demand.log'), + ]); + $handler = $logger->getLogger()->getHandlers()[0]; + + $this->assertInstanceOf(StreamHandler::class, $handler); + + $url = new ReflectionProperty(get_class($handler), 'url'); + $url->setAccessible(true); + + $this->assertSame(storage_path('logs/on-demand.log'), $url->getValue($handler)); + } + + public function testLogManagerCanUseOnDemandChannelInOnDemandStack() + { + $manager = new LogManager($this->app); + $this->app['config']->set('logging.channels.test', [ + 'driver' => 'single', + ]); + + $factory = new class() + { + public function __invoke() + { + return new Monolog( + 'uuid', + [new StreamHandler(storage_path('logs/custom.log'))], + [new UidProcessor()] + ); + } + }; + $channel = $manager->build([ + 'driver' => 'custom', + 'via' => get_class($factory), + ]); + $logger = $manager->stack(['test', $channel]); + + $handler = $logger->getLogger()->getHandlers()[1]; + $processor = $logger->getLogger()->getProcessors()[0]; + + $this->assertInstanceOf(StreamHandler::class, $handler); + $this->assertInstanceOf(UidProcessor::class, $processor); + + $url = new ReflectionProperty(get_class($handler), 'url'); + $url->setAccessible(true); + + $this->assertSame(storage_path('logs/custom.log'), $url->getValue($handler)); + } + + public function testWrappingHandlerInFingersCrossedWhenActionLevelIsUsed() + { + $config = $this->app['config']; + + $config->set('logging.channels.fingerscrossed', [ + 'driver' => 'monolog', + 'handler' => StreamHandler::class, + 'level' => 'debug', + 'action_level' => 'critical', + 'with' => [ + 'stream' => 'php://stderr', + 'bubble' => false, + ], + ]); + + $manager = new LogManager($this->app); + + // create logger with handler specified from configuration + $logger = $manager->channel('fingerscrossed'); + $handlers = $logger->getLogger()->getHandlers(); + + $this->assertInstanceOf(Logger::class, $logger); + $this->assertCount(1, $handlers); + + $expectedFingersCrossedHandler = $handlers[0]; + $this->assertInstanceOf(FingersCrossedHandler::class, $expectedFingersCrossedHandler); + + $activationStrategyProp = new ReflectionProperty(get_class($expectedFingersCrossedHandler), 'activationStrategy'); + $activationStrategyProp->setAccessible(true); + $activationStrategyValue = $activationStrategyProp->getValue($expectedFingersCrossedHandler); + + $actionLevelProp = new ReflectionProperty(get_class($activationStrategyValue), 'actionLevel'); + $actionLevelProp->setAccessible(true); + $actionLevelValue = $actionLevelProp->getValue($activationStrategyValue); + + $this->assertEquals(Monolog::CRITICAL, $actionLevelValue); + + if (method_exists($expectedFingersCrossedHandler, 'getHandler')) { + $expectedStreamHandler = $expectedFingersCrossedHandler->getHandler(); + } else { + $handlerProp = new ReflectionProperty(get_class($expectedFingersCrossedHandler), 'handler'); + $handlerProp->setAccessible(true); + $expectedStreamHandler = $handlerProp->getValue($expectedFingersCrossedHandler); + } + $this->assertInstanceOf(StreamHandler::class, $expectedStreamHandler); + $this->assertEquals(Monolog::DEBUG, $expectedStreamHandler->getLevel()); + } } diff --git a/tests/Mail/MailFailoverTransportTest.php b/tests/Mail/MailFailoverTransportTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6d8c8fec8bd9078c9f8855cab34f74de6f2d4cb0 --- /dev/null +++ b/tests/Mail/MailFailoverTransportTest.php @@ -0,0 +1,63 @@ +<?php + +namespace Illuminate\Tests\Mail; + +use Illuminate\Mail\Transport\ArrayTransport; +use Orchestra\Testbench\TestCase; + +class MailFailoverTransportTest extends TestCase +{ + public function testGetFailoverTransportWithConfiguredTransports() + { + $this->app['config']->set('mail.default', 'failover'); + + $this->app['config']->set('mail.mailers', [ + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'sendmail', + 'array', + ], + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => '/usr/sbin/sendmail -bs', + ], + + 'array' => [ + 'transport' => 'array', + ], + ]); + + $transport = app('mailer')->getSwiftMailer()->getTransport(); + $this->assertInstanceOf(\Swift_FailoverTransport::class, $transport); + + $transports = $transport->getTransports(); + $this->assertCount(2, $transports); + $this->assertInstanceOf(\Swift_SendmailTransport::class, $transports[0]); + $this->assertEquals('/usr/sbin/sendmail -bs', $transports[0]->getCommand()); + $this->assertInstanceOf(ArrayTransport::class, $transports[1]); + } + + public function testGetFailoverTransportWithLaravel6StyleMailConfiguration() + { + $this->app['config']->set('mail.driver', 'failover'); + + $this->app['config']->set('mail.mailers', [ + 'sendmail', + 'array', + ]); + + $this->app['config']->set('mail.sendmail', '/usr/sbin/sendmail -bs'); + + $transport = app('mailer')->getSwiftMailer()->getTransport(); + $this->assertInstanceOf(\Swift_FailoverTransport::class, $transport); + + $transports = $transport->getTransports(); + $this->assertCount(2, $transports); + $this->assertInstanceOf(\Swift_SendmailTransport::class, $transports[0]); + $this->assertEquals('/usr/sbin/sendmail -bs', $transports[0]->getCommand()); + $this->assertInstanceOf(ArrayTransport::class, $transports[1]); + } +} diff --git a/tests/Mail/MailLogTransportTest.php b/tests/Mail/MailLogTransportTest.php index cbc26a042991de3e51830bd7e15657db17fd0e3d..5848734d2eec1ce8bbfebf324abbc1aa96886b49 100644 --- a/tests/Mail/MailLogTransportTest.php +++ b/tests/Mail/MailLogTransportTest.php @@ -13,15 +13,16 @@ class MailLogTransportTest extends TestCase { public function testGetLogTransportWithConfiguredChannel() { + $this->app['config']->set('mail.driver', 'log'); + $this->app['config']->set('mail.log_channel', 'mail'); + $this->app['config']->set('logging.channels.mail', [ 'driver' => 'single', 'path' => 'mail.log', ]); - $manager = $this->app['swift.transport']; - - $transport = $manager->driver('log'); + $transport = app('mailer')->getSwiftMailer()->getTransport(); $this->assertInstanceOf(LogTransport::class, $transport); $logger = $transport->logger(); @@ -34,10 +35,11 @@ class MailLogTransportTest extends TestCase public function testGetLogTransportWithPsrLogger() { - $logger = $this->app->instance('log', new NullLogger()); + $this->app['config']->set('mail.driver', 'log'); + $logger = $this->app->instance('log', new NullLogger); - $manager = $this->app['swift.transport']; + $transportLogger = app('mailer')->getSwiftMailer()->getTransport()->logger(); - $this->assertEquals($logger, $manager->driver('log')->logger()); + $this->assertEquals($logger, $transportLogger); } } diff --git a/tests/Mail/MailMailableTest.php b/tests/Mail/MailMailableTest.php index 5a6ab43e870278faca28c8a4d1daff1c6a82ce48..f1ca7e21a6ad8fc3bddbfe13ea3e61d4dd1f9397 100644 --- a/tests/Mail/MailMailableTest.php +++ b/tests/Mail/MailMailableTest.php @@ -52,6 +52,139 @@ class MailMailableTest extends TestCase ], $mailable->to); $this->assertTrue($mailable->hasTo(new MailableTestUserStub)); $this->assertTrue($mailable->hasTo('taylor@laravel.com')); + + foreach (['', null, [], false] as $address) { + $mailable = new WelcomeMailableStub; + $mailable->to($address); + $this->assertFalse($mailable->hasTo(new MailableTestUserStub)); + $this->assertFalse($mailable->hasTo($address)); + } + } + + public function testMailableSetsCcRecipientsCorrectly() + { + $mailable = new WelcomeMailableStub; + $mailable->cc('taylor@laravel.com'); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc('taylor@laravel.com', 'Taylor Otwell'); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc(['taylor@laravel.com']); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + $this->assertFalse($mailable->hasCc('taylor@laravel.com', 'Taylor Otwell')); + + $mailable = new WelcomeMailableStub; + $mailable->cc([['name' => 'Taylor Otwell', 'email' => 'taylor@laravel.com']]); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc(new MailableTestUserStub); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc(collect([new MailableTestUserStub])); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->cc); + $this->assertTrue($mailable->hasCc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc(collect([new MailableTestUserStub, new MailableTestUserStub])); + $this->assertEquals([ + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ], $mailable->cc); + $this->assertTrue($mailable->hasCc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->cc(['taylor@laravel.com', 'not-taylor@laravel.com']); + $this->assertEquals([ + ['name' => null, 'address' => 'taylor@laravel.com'], + ['name' => null, 'address' => 'not-taylor@laravel.com'], + ], $mailable->cc); + $this->assertTrue($mailable->hasCc('taylor@laravel.com')); + $this->assertTrue($mailable->hasCc('not-taylor@laravel.com')); + + foreach (['', null, [], false] as $address) { + $mailable = new WelcomeMailableStub; + $mailable->cc($address); + $this->assertFalse($mailable->hasCc(new MailableTestUserStub)); + $this->assertFalse($mailable->hasCc($address)); + } + } + + public function testMailableSetsBccRecipientsCorrectly() + { + $mailable = new WelcomeMailableStub; + $mailable->bcc('taylor@laravel.com'); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc('taylor@laravel.com', 'Taylor Otwell'); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc(['taylor@laravel.com']); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + $this->assertFalse($mailable->hasBcc('taylor@laravel.com', 'Taylor Otwell')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc([['name' => 'Taylor Otwell', 'email' => 'taylor@laravel.com']]); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc(new MailableTestUserStub); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc(collect([new MailableTestUserStub])); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->bcc); + $this->assertTrue($mailable->hasBcc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc(collect([new MailableTestUserStub, new MailableTestUserStub])); + $this->assertEquals([ + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ], $mailable->bcc); + $this->assertTrue($mailable->hasBcc(new MailableTestUserStub)); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->bcc(['taylor@laravel.com', 'not-taylor@laravel.com']); + $this->assertEquals([ + ['name' => null, 'address' => 'taylor@laravel.com'], + ['name' => null, 'address' => 'not-taylor@laravel.com'], + ], $mailable->bcc); + $this->assertTrue($mailable->hasBcc('taylor@laravel.com')); + $this->assertTrue($mailable->hasBcc('not-taylor@laravel.com')); + + foreach (['', null, [], false] as $address) { + $mailable = new WelcomeMailableStub; + $mailable->bcc($address); + $this->assertFalse($mailable->hasBcc(new MailableTestUserStub)); + $this->assertFalse($mailable->hasBcc($address)); + } } public function testMailableSetsReplyToCorrectly() @@ -99,6 +232,67 @@ class MailMailableTest extends TestCase ], $mailable->replyTo); $this->assertTrue($mailable->hasReplyTo(new MailableTestUserStub)); $this->assertTrue($mailable->hasReplyTo('taylor@laravel.com')); + + foreach (['', null, [], false] as $address) { + $mailable = new WelcomeMailableStub; + $mailable->replyTo($address); + $this->assertFalse($mailable->hasReplyTo(new MailableTestUserStub)); + $this->assertFalse($mailable->hasReplyTo($address)); + } + } + + public function testMailableSetsFromCorrectly() + { + $mailable = new WelcomeMailableStub; + $mailable->from('taylor@laravel.com'); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->from('taylor@laravel.com', 'Taylor Otwell'); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->from(['taylor@laravel.com']); + $this->assertEquals([['name' => null, 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + $this->assertFalse($mailable->hasFrom('taylor@laravel.com', 'Taylor Otwell')); + + $mailable = new WelcomeMailableStub; + $mailable->from([['name' => 'Taylor Otwell', 'email' => 'taylor@laravel.com']]); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com', 'Taylor Otwell')); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->from(new MailableTestUserStub); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom(new MailableTestUserStub)); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->from(collect([new MailableTestUserStub])); + $this->assertEquals([['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com']], $mailable->from); + $this->assertTrue($mailable->hasFrom(new MailableTestUserStub)); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + $mailable = new WelcomeMailableStub; + $mailable->from(collect([new MailableTestUserStub, new MailableTestUserStub])); + $this->assertEquals([ + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell', 'address' => 'taylor@laravel.com'], + ], $mailable->from); + $this->assertTrue($mailable->hasFrom(new MailableTestUserStub)); + $this->assertTrue($mailable->hasFrom('taylor@laravel.com')); + + foreach (['', null, [], false] as $address) { + $mailable = new WelcomeMailableStub; + $mailable->from($address); + $this->assertFalse($mailable->hasFrom(new MailableTestUserStub)); + $this->assertFalse($mailable->hasFrom($address)); + } } public function testItIgnoresDuplicatedRawAttachments() @@ -212,6 +406,17 @@ class MailMailableTest extends TestCase $this->assertSame($expected, $mailable->buildViewData()); } + + public function testMailerMayBeSet() + { + $mailable = new WelcomeMailableStub; + + $mailable->mailer('array'); + + $mailable = unserialize(serialize($mailable)); + + $this->assertSame('array', $mailable->mailer); + } } class WelcomeMailableStub extends Mailable diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php index 3a3ef125f1c4e890351a151583c2ecafd2756f84..fcaea939d8f60df0563a8922d378f7a54bb5ea0c 100755 --- a/tests/Mail/MailMailerTest.php +++ b/tests/Mail/MailMailerTest.php @@ -26,7 +26,7 @@ class MailMailerTest extends TestCase public function testMailerSendSendsMessageWithProperViewContent() { unset($_SERVER['__mailer.test']); - $mailer = $this->getMockBuilder(Mailer::class)->setMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); + $mailer = $this->getMockBuilder(Mailer::class)->onlyMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); $message = m::mock(Swift_Mime_SimpleMessage::class); $mailer->expects($this->once())->method('createMessage')->willReturn($message); $view = m::mock(stdClass::class); @@ -46,7 +46,7 @@ class MailMailerTest extends TestCase public function testMailerSendSendsMessageWithProperViewContentUsingHtmlStrings() { unset($_SERVER['__mailer.test']); - $mailer = $this->getMockBuilder(Mailer::class)->setMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); + $mailer = $this->getMockBuilder(Mailer::class)->onlyMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); $message = m::mock(Swift_Mime_SimpleMessage::class); $mailer->expects($this->once())->method('createMessage')->willReturn($message); $view = m::mock(stdClass::class); @@ -67,7 +67,7 @@ class MailMailerTest extends TestCase public function testMailerSendSendsMessageWithProperViewContentUsingHtmlMethod() { unset($_SERVER['__mailer.test']); - $mailer = $this->getMockBuilder(Mailer::class)->setMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); + $mailer = $this->getMockBuilder(Mailer::class)->onlyMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); $message = m::mock(Swift_Mime_SimpleMessage::class); $mailer->expects($this->once())->method('createMessage')->willReturn($message); $view = m::mock(stdClass::class); @@ -87,7 +87,7 @@ class MailMailerTest extends TestCase public function testMailerSendSendsMessageWithProperPlainViewContent() { unset($_SERVER['__mailer.test']); - $mailer = $this->getMockBuilder(Mailer::class)->setMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); + $mailer = $this->getMockBuilder(Mailer::class)->onlyMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); $message = m::mock(Swift_Mime_SimpleMessage::class); $mailer->expects($this->once())->method('createMessage')->willReturn($message); $view = m::mock(stdClass::class); @@ -109,7 +109,7 @@ class MailMailerTest extends TestCase public function testMailerSendSendsMessageWithProperPlainViewContentWhenExplicit() { unset($_SERVER['__mailer.test']); - $mailer = $this->getMockBuilder(Mailer::class)->setMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); + $mailer = $this->getMockBuilder(Mailer::class)->onlyMethods(['createMessage'])->setConstructorArgs($this->getMocks())->getMock(); $message = m::mock(Swift_Mime_SimpleMessage::class); $mailer->expects($this->once())->method('createMessage')->willReturn($message); $view = m::mock(stdClass::class); @@ -145,6 +145,57 @@ class MailMailerTest extends TestCase }); } + public function testGlobalReplyToIsRespectedOnAllMessages() + { + unset($_SERVER['__mailer.test']); + $mailer = $this->getMailer(); + $view = m::mock(stdClass::class); + $mailer->getViewFactory()->shouldReceive('make')->once()->andReturn($view); + $view->shouldReceive('render')->once()->andReturn('rendered.view'); + $this->setSwiftMailer($mailer); + $mailer->alwaysReplyTo('taylorotwell@gmail.com', 'Taylor Otwell'); + $mailer->getSwiftMailer()->shouldReceive('send')->once()->with(m::type(Swift_Message::class), [])->andReturnUsing(function ($message) { + $this->assertEquals(['taylorotwell@gmail.com' => 'Taylor Otwell'], $message->getReplyTo()); + }); + $mailer->send('foo', ['data'], function ($m) { + // + }); + } + + public function testGlobalToIsRespectedOnAllMessages() + { + unset($_SERVER['__mailer.test']); + $mailer = $this->getMailer(); + $view = m::mock(stdClass::class); + $mailer->getViewFactory()->shouldReceive('make')->once()->andReturn($view); + $view->shouldReceive('render')->once()->andReturn('rendered.view'); + $this->setSwiftMailer($mailer); + $mailer->alwaysTo('taylorotwell@gmail.com', 'Taylor Otwell'); + $mailer->getSwiftMailer()->shouldReceive('send')->once()->with(m::type(Swift_Message::class), [])->andReturnUsing(function ($message) { + $this->assertEquals(['taylorotwell@gmail.com' => 'Taylor Otwell'], $message->getTo()); + }); + $mailer->send('foo', ['data'], function ($m) { + // + }); + } + + public function testGlobalReturnPathIsRespectedOnAllMessages() + { + unset($_SERVER['__mailer.test']); + $mailer = $this->getMailer(); + $view = m::mock(stdClass::class); + $mailer->getViewFactory()->shouldReceive('make')->once()->andReturn($view); + $view->shouldReceive('render')->once()->andReturn('rendered.view'); + $this->setSwiftMailer($mailer); + $mailer->alwaysReturnPath('taylorotwell@gmail.com'); + $mailer->getSwiftMailer()->shouldReceive('send')->once()->with(m::type(Swift_Message::class), [])->andReturnUsing(function ($message) { + $this->assertSame('taylorotwell@gmail.com', $message->getReturnPath()); + }); + $mailer->send('foo', ['data'], function ($m) { + // + }); + } + public function testFailedRecipientsAreAppendedAndCanBeRetrieved() { unset($_SERVER['__mailer.test']); @@ -196,7 +247,7 @@ class MailMailerTest extends TestCase protected function getMailer($events = null) { - return new Mailer(m::mock(Factory::class), m::mock(Swift_Mailer::class), $events); + return new Mailer('smtp', m::mock(Factory::class), m::mock(Swift_Mailer::class), $events); } public function setSwiftMailer($mailer) @@ -212,7 +263,7 @@ class MailMailerTest extends TestCase protected function getMocks() { - return [m::mock(Factory::class), m::mock(Swift_Mailer::class)]; + return ['smtp', m::mock(Factory::class), m::mock(Swift_Mailer::class)]; } } diff --git a/tests/Mail/MailManagerTest.php b/tests/Mail/MailManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e7610ffe1cdd78e482ccc7d99acb4b6ac0775e3d --- /dev/null +++ b/tests/Mail/MailManagerTest.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Tests\Mail; + +use InvalidArgumentException; +use Orchestra\Testbench\TestCase; + +class MailManagerTest extends TestCase +{ + /** + * @dataProvider emptyTransportConfigDataProvider + */ + public function testEmptyTransportConfig($transport) + { + $this->app['config']->set('mail.mailers.custom_smtp', [ + 'transport' => $transport, + 'host' => null, + 'port' => null, + 'encryption' => null, + 'username' => null, + 'password' => null, + 'timeout' => null, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Unsupported mail transport [{$transport}]"); + $this->app['mail.manager']->mailer('custom_smtp'); + } + + public function emptyTransportConfigDataProvider() + { + return [ + [null], [''], [' '], + ]; + } +} diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php index 7ad78c8d7b24dbdc0222b2b0af79a819576b55d8..19341fde67a08487d5448807f803116281970992 100644 --- a/tests/Mail/MailMarkdownTest.php +++ b/tests/Mail/MailMarkdownTest.php @@ -20,13 +20,14 @@ class MailMarkdownTest extends TestCase $markdown = new Markdown($viewFactory); $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); + $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); $viewFactory->shouldReceive('render')->twice()->andReturn('<html></html>', 'body {}'); $result = $markdown->render('view', []); - $this->assertTrue(strpos($result, '<html></html>') !== false); + $this->assertNotFalse(strpos($result, '<html></html>')); } public function testRenderFunctionReturnsHtmlWithCustomTheme() @@ -36,13 +37,31 @@ class MailMarkdownTest extends TestCase $markdown->theme('yaz'); $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); + $viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true); $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); - $viewFactory->shouldReceive('make')->with('mail::themes.yaz', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf(); $viewFactory->shouldReceive('render')->twice()->andReturn('<html></html>', 'body {}'); $result = $markdown->render('view', []); - $this->assertTrue(strpos($result, '<html></html>') !== false); + $this->assertNotFalse(strpos($result, '<html></html>')); + } + + public function testRenderFunctionReturnsHtmlWithCustomThemeWithMailPrefix() + { + $viewFactory = m::mock(Factory::class); + $markdown = new Markdown($viewFactory); + $markdown->theme('mail.yaz'); + $viewFactory->shouldReceive('flushFinderCache')->once(); + $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); + $viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf(); + $viewFactory->shouldReceive('render')->twice()->andReturn('<html></html>', 'body {}'); + + $result = $markdown->render('view', []); + + $this->assertNotFalse(strpos($result, '<html></html>')); } public function testRenderTextReturnsText() diff --git a/tests/Mail/MailMessageTest.php b/tests/Mail/MailMessageTest.php index 5ecec53a1f5a5c3a36735e61a907898cc4c6ddf3..fa752a960c16f95a6a088e659d5f72a2ed7fafe7 100755 --- a/tests/Mail/MailMessageTest.php +++ b/tests/Mail/MailMessageTest.php @@ -101,7 +101,7 @@ class MailMessageTest extends TestCase public function testBasicAttachment() { $swift = m::mock(stdClass::class); - $message = $this->getMockBuilder(Message::class)->setMethods(['createAttachmentFromPath'])->setConstructorArgs([$swift])->getMock(); + $message = $this->getMockBuilder(Message::class)->onlyMethods(['createAttachmentFromPath'])->setConstructorArgs([$swift])->getMock(); $attachment = m::mock(stdClass::class); $message->expects($this->once())->method('createAttachmentFromPath')->with($this->equalTo('foo.jpg'))->willReturn($attachment); $swift->shouldReceive('attach')->once()->with($attachment); @@ -113,7 +113,7 @@ class MailMessageTest extends TestCase public function testDataAttachment() { $swift = m::mock(stdClass::class); - $message = $this->getMockBuilder(Message::class)->setMethods(['createAttachmentFromData'])->setConstructorArgs([$swift])->getMock(); + $message = $this->getMockBuilder(Message::class)->onlyMethods(['createAttachmentFromData'])->setConstructorArgs([$swift])->getMock(); $attachment = m::mock(stdClass::class); $message->expects($this->once())->method('createAttachmentFromData')->with($this->equalTo('foo'), $this->equalTo('name'))->willReturn($attachment); $swift->shouldReceive('attach')->once()->with($attachment); diff --git a/tests/Mail/MailSesTransportTest.php b/tests/Mail/MailSesTransportTest.php index 92f5b8f6d754a54e7cd031c35c426fa7f4d896f0..ef31a16de489d74cd84a6b47eee3b7824d0c5d26 100644 --- a/tests/Mail/MailSesTransportTest.php +++ b/tests/Mail/MailSesTransportTest.php @@ -5,18 +5,19 @@ namespace Illuminate\Tests\Mail; use Aws\Ses\SesClient; use Illuminate\Config\Repository; use Illuminate\Container\Container; +use Illuminate\Mail\MailManager; use Illuminate\Mail\Transport\SesTransport; -use Illuminate\Mail\TransportManager; use Illuminate\Support\Str; +use Illuminate\View\Factory; use PHPUnit\Framework\TestCase; use Swift_Message; class MailSesTransportTest extends TestCase { - /** @group Foo */ public function testGetTransport() { - $container = new Container(); + $container = new Container; + $container->singleton('config', function () { return new Repository([ 'services.ses' => [ @@ -27,12 +28,11 @@ class MailSesTransportTest extends TestCase ]); }); - $manager = new TransportManager($container); + $manager = new MailManager($container); - /** @var SesTransport $transport */ - $transport = $manager->driver('ses'); + /** @var \Illuminate\Mail\Transport\SesTransport $transport */ + $transport = $manager->createTransport(['transport' => 'ses']); - /** @var SesClient $ses */ $ses = $transport->ses(); $this->assertSame('us-east-1', $ses->getRegion()); @@ -46,15 +46,15 @@ class MailSesTransportTest extends TestCase $message->setBcc('you@example.com'); $client = $this->getMockBuilder(SesClient::class) - ->setMethods(['sendRawEmail']) + ->addMethods(['sendRawEmail']) ->disableOriginalConstructor() ->getMock(); $transport = new SesTransport($client); // Generate a messageId for our mock to return to ensure that the post-sent message - // has X-SES-Message-ID in its headers + // has X-Message-ID in its headers $messageId = Str::random(32); - $sendRawEmailMock = new sendRawEmailMock($messageId); + $sendRawEmailMock = new SendRawEmailMock($messageId); $client->expects($this->once()) ->method('sendRawEmail') ->with($this->equalTo([ @@ -64,11 +64,65 @@ class MailSesTransportTest extends TestCase ->willReturn($sendRawEmailMock); $transport->send($message); + + $this->assertEquals($messageId, $message->getHeaders()->get('X-Message-ID')->getFieldBody()); $this->assertEquals($messageId, $message->getHeaders()->get('X-SES-Message-ID')->getFieldBody()); } + + public function testSesLocalConfiguration() + { + $container = new Container; + + $container->singleton('config', function () { + return new Repository([ + 'mail' => [ + 'mailers' => [ + 'ses' => [ + 'transport' => 'ses', + 'region' => 'eu-west-1', + 'options' => [ + 'ConfigurationSetName' => 'Laravel', + 'Tags' => [ + ['Name' => 'Laravel', 'Value' => 'Framework'], + ], + ], + ], + ], + ], + 'services' => [ + 'ses' => [ + 'region' => 'us-east-1', + ], + ], + ]); + }); + + $container->instance('view', $this->createMock(Factory::class)); + + $container->bind('events', function () { + return null; + }); + + $manager = new MailManager($container); + + /** @var \Illuminate\Mail\Mailer $mailer */ + $mailer = $manager->mailer('ses'); + + /** @var \Illuminate\Mail\Transport\SesTransport $transport */ + $transport = $mailer->getSwiftMailer()->getTransport(); + + $this->assertSame('eu-west-1', $transport->ses()->getRegion()); + + $this->assertSame([ + 'ConfigurationSetName' => 'Laravel', + 'Tags' => [ + ['Name' => 'Laravel', 'Value' => 'Framework'], + ], + ], $transport->getOptions()); + } } -class sendRawEmailMock +class SendRawEmailMock { protected $getResponse; @@ -77,11 +131,6 @@ class sendRawEmailMock $this->getResponse = $responseValue; } - /** - * Mock the get() call for the sendRawEmail response. - * @param [type] $key [description] - * @return [type] [description] - */ public function get($key) { return $this->getResponse; diff --git a/tests/Mail/MailableQueuedTest.php b/tests/Mail/MailableQueuedTest.php index 094a706716330ad67bbce7c44ea73574910c282b..47b93429e62ecef562671b72c86a2d26e5c0042d 100644 --- a/tests/Mail/MailableQueuedTest.php +++ b/tests/Mail/MailableQueuedTest.php @@ -29,10 +29,10 @@ class MailableQueuedTest extends TestCase $queueFake = new QueueFake(new Application); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) - ->setMethods(['createMessage', 'to']) + ->onlyMethods(['createMessage', 'to']) ->getMock(); $mailer->setQueue($queueFake); - $mailable = new MailableQueableStub; + $mailable = new MailableQueueableStub; $queueFake->assertNothingPushed(); $mailer->send($mailable); $queueFake->assertPushedOn(null, SendQueuedMailable::class); @@ -43,10 +43,10 @@ class MailableQueuedTest extends TestCase $queueFake = new QueueFake(new Application); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) - ->setMethods(['createMessage']) + ->onlyMethods(['createMessage']) ->getMock(); $mailer->setQueue($queueFake); - $mailable = new MailableQueableStub; + $mailable = new MailableQueueableStub; $attachmentOption = ['mime' => 'image/jpeg', 'as' => 'bar.jpg']; $mailable->attach('foo.jpg', $attachmentOption); $this->assertIsArray($mailable->attachments); @@ -72,10 +72,10 @@ class MailableQueuedTest extends TestCase $queueFake = new QueueFake($app); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) - ->setMethods(['createMessage']) + ->onlyMethods(['createMessage']) ->getMock(); $mailer->setQueue($queueFake); - $mailable = new MailableQueableStub; + $mailable = new MailableQueueableStub; $attachmentOption = ['mime' => 'image/jpeg', 'as' => 'bar.jpg']; $mailable->attachFromStorage('/', 'foo.jpg', $attachmentOption); @@ -91,11 +91,11 @@ class MailableQueuedTest extends TestCase protected function getMocks() { - return [m::mock(Factory::class), m::mock(Swift_Mailer::class)]; + return ['smtp', m::mock(Factory::class), m::mock(Swift_Mailer::class)]; } } -class MailableQueableStub extends Mailable implements ShouldQueue +class MailableQueueableStub extends Mailable implements ShouldQueue { use Queueable; diff --git a/tests/Notifications/NotificationBroadcastChannelTest.php b/tests/Notifications/NotificationBroadcastChannelTest.php index 033cf3f8b3f36965f44edaf2ee44c1e82d80b062..51c9401671fa4fe97253be2c4512b20606b7de11 100644 --- a/tests/Notifications/NotificationBroadcastChannelTest.php +++ b/tests/Notifications/NotificationBroadcastChannelTest.php @@ -83,11 +83,26 @@ class NotificationBroadcastChannelTest extends TestCase $events = m::mock(Dispatcher::class); $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) { - return $event->connection == 'sync'; + return $event->connection === 'sync'; })); $channel = new BroadcastChannel($events); $channel->send($notifiable, $notification); } + + public function testNotificationIsBroadcastedWithCustomAdditionalPayload() + { + $notification = new CustomBroadcastWithTestNotification; + $notification->id = 1; + $notifiable = m::mock(); + + $event = new BroadcastNotificationCreated( + $notifiable, $notification, $notification->toArray($notifiable) + ); + + $data = $event->broadcastWith(); + + $this->assertArrayHasKey('additional', $data); + } } class NotificationBroadcastChannelTestNotification extends Notification @@ -136,3 +151,16 @@ class TestNotificationBroadCastedNow extends Notification return (new BroadcastMessage([]))->onConnection('sync'); } } + +class CustomBroadcastWithTestNotification extends Notification +{ + public function toArray($notifiable) + { + return ['invoice_id' => 1]; + } + + public function broadcastWith() + { + return ['id' => 1, 'type' => 'custom', 'additional' => 'custom']; + } +} diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 403d4af94697f1825673db221ec31c18c7bb92c9..efcce081aeee2a392631b88fba38451bcb04817e 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -58,6 +58,37 @@ class NotificationChannelManagerTest extends TestCase $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestNotificationWithTwoChannels); } + public function testNotificationNotSentWhenCancelled() + { + $container = new Container; + $container->instance('config', ['app.name' => 'Name', 'app.logo' => 'Logo']); + $container->instance(Bus::class, $bus = m::mock()); + $container->instance(Dispatcher::class, $events = m::mock()); + Container::setInstance($container); + $manager = m::mock(ChannelManager::class.'[driver]', [$container]); + $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); + $manager->shouldNotReceive('driver'); + $events->shouldNotReceive('dispatch'); + + $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestCancelledNotification); + } + + public function testNotificationSentWhenNotCancelled() + { + $container = new Container; + $container->instance('config', ['app.name' => 'Name', 'app.logo' => 'Logo']); + $container->instance(Bus::class, $bus = m::mock()); + $container->instance(Dispatcher::class, $events = m::mock()); + Container::setInstance($container); + $manager = m::mock(ChannelManager::class.'[driver]', [$container]); + $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); + $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); + $driver->shouldReceive('send')->once(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSent::class)); + + $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestNotCancelledNotification); + } + public function testNotificationCanBeQueued() { $container = new Container; @@ -103,6 +134,42 @@ class NotificationChannelManagerTestNotificationWithTwoChannels extends Notifica } } +class NotificationChannelManagerTestCancelledNotification extends Notification +{ + public function via() + { + return ['test']; + } + + public function message() + { + return $this->line('test')->action('Text', 'url'); + } + + public function shouldSend($notifiable, $channel) + { + return false; + } +} + +class NotificationChannelManagerTestNotCancelledNotification extends Notification +{ + public function via() + { + return ['test']; + } + + public function message() + { + return $this->line('test')->action('Text', 'url'); + } + + public function shouldSend($notifiable, $channel) + { + return true; + } +} + class NotificationChannelManagerTestQueuedNotification extends Notification implements ShouldQueue { use Queueable; diff --git a/tests/Notifications/NotificationDatabaseChannelTest.php b/tests/Notifications/NotificationDatabaseChannelTest.php index 9abcedd5393ae611702a3a0338fd12f2f90172b1..3a45a65a3fc59832f50c439cc46aef431a7c8d61 100644 --- a/tests/Notifications/NotificationDatabaseChannelTest.php +++ b/tests/Notifications/NotificationDatabaseChannelTest.php @@ -49,6 +49,24 @@ class NotificationDatabaseChannelTest extends TestCase $channel = new ExtendedDatabaseChannel; $channel->send($notifiable, $notification); } + + public function testCustomizeTypeIsSentToDatabase() + { + $notification = new NotificationDatabaseChannelCustomizeTypeTestNotification; + $notification->id = 1; + $notifiable = m::mock(); + + $notifiable->shouldReceive('routeNotificationFor->create')->with([ + 'id' => 1, + 'type' => 'MONTHLY', + 'data' => ['invoice_id' => 1], + 'read_at' => null, + 'something' => 'else', + ]); + + $channel = new ExtendedDatabaseChannel; + $channel->send($notifiable, $notification); + } } class NotificationDatabaseChannelTestNotification extends Notification @@ -59,6 +77,19 @@ class NotificationDatabaseChannelTestNotification extends Notification } } +class NotificationDatabaseChannelCustomizeTypeTestNotification extends Notification +{ + public function toDatabase($notifiable) + { + return new DatabaseMessage(['invoice_id' => 1]); + } + + public function databaseType() + { + return 'MONTHLY'; + } +} + class ExtendedDatabaseChannel extends DatabaseChannel { protected function buildPayload($notifiable, Notification $notification) diff --git a/tests/Notifications/NotificationMailMessageTest.php b/tests/Notifications/NotificationMailMessageTest.php index bcd8b0cc3de65b692ac9c952534e4b7812762690..ba31b96df35363b49db4e0fb80b38975a881054b 100644 --- a/tests/Notifications/NotificationMailMessageTest.php +++ b/tests/Notifications/NotificationMailMessageTest.php @@ -18,6 +18,52 @@ class NotificationMailMessageTest extends TestCase $this->assertSame('notifications::foo', $message->markdown); } + public function testHtmlAndPlainView() + { + $message = new MailMessage; + + $this->assertNull($message->view); + $this->assertSame([], $message->viewData); + + $message->view(['notifications::foo', 'notifications::bar'], [ + 'foo' => 'bar', + ]); + + $this->assertSame('notifications::foo', $message->view[0]); + $this->assertSame('notifications::bar', $message->view[1]); + $this->assertSame(['foo' => 'bar'], $message->viewData); + } + + public function testHtmlView() + { + $message = new MailMessage; + + $this->assertNull($message->view); + $this->assertSame([], $message->viewData); + + $message->view('notifications::foo', [ + 'foo' => 'bar', + ]); + + $this->assertSame('notifications::foo', $message->view); + $this->assertSame(['foo' => 'bar'], $message->viewData); + } + + public function testPlainView() + { + $message = new MailMessage; + + $this->assertNull($message->view); + $this->assertSame([], $message->viewData); + + $message->view([null, 'notifications::foo'], [ + 'foo' => 'bar', + ]); + + $this->assertSame('notifications::foo', $message->view[1]); + $this->assertSame(['foo' => 'bar'], $message->viewData); + } + public function testCcIsSetCorrectly() { $message = new MailMessage; @@ -86,4 +132,122 @@ class NotificationMailMessageTest extends TestCase $this->assertSame([$callback], $message->callbacks); } + + public function testWhenCallback() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertTrue($condition); + + $mailMessage->cc('cc@example.com'); + }; + + $message = new MailMessage; + $message->when(true, $callback); + $this->assertSame([['cc@example.com', null]], $message->cc); + + $message = new MailMessage; + $message->when(false, $callback); + $this->assertSame([], $message->cc); + } + + public function testWhenCallbackWithReturn() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertTrue($condition); + + return $mailMessage->cc('cc@example.com'); + }; + + $message = new MailMessage; + $message->when(true, $callback)->bcc('bcc@example.com'); + $this->assertSame([['cc@example.com', null]], $message->cc); + $this->assertSame([['bcc@example.com', null]], $message->bcc); + + $message = new MailMessage; + $message->when(false, $callback)->bcc('bcc@example.com'); + $this->assertSame([], $message->cc); + $this->assertSame([['bcc@example.com', null]], $message->bcc); + } + + public function testWhenCallbackWithDefault() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertSame('truthy', $condition); + + $mailMessage->cc('truthy@example.com'); + }; + + $default = function (MailMessage $mailMessage, $condition) { + $this->assertEquals(0, $condition); + + $mailMessage->cc('zero@example.com'); + }; + + $message = new MailMessage; + $message->when('truthy', $callback, $default); + $this->assertSame([['truthy@example.com', null]], $message->cc); + + $message = new MailMessage; + $message->when(0, $callback, $default); + $this->assertSame([['zero@example.com', null]], $message->cc); + } + + public function testUnlessCallback() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertFalse($condition); + + $mailMessage->cc('test@example.com'); + }; + + $message = new MailMessage; + $message->unless(false, $callback); + $this->assertSame([['test@example.com', null]], $message->cc); + + $message = new MailMessage; + $message->unless(true, $callback); + $this->assertSame([], $message->cc); + } + + public function testUnlessCallbackWithReturn() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertFalse($condition); + + return $mailMessage->cc('cc@example.com'); + }; + + $message = new MailMessage; + $message->unless(false, $callback)->bcc('bcc@example.com'); + $this->assertSame([['cc@example.com', null]], $message->cc); + $this->assertSame([['bcc@example.com', null]], $message->bcc); + + $message = new MailMessage; + $message->unless(true, $callback)->bcc('bcc@example.com'); + $this->assertSame([], $message->cc); + $this->assertSame([['bcc@example.com', null]], $message->bcc); + } + + public function testUnlessCallbackWithDefault() + { + $callback = function (MailMessage $mailMessage, $condition) { + $this->assertEquals(0, $condition); + + $mailMessage->cc('zero@example.com'); + }; + + $default = function (MailMessage $mailMessage, $condition) { + $this->assertSame('truthy', $condition); + + $mailMessage->cc('truthy@example.com'); + }; + + $message = new MailMessage; + $message->unless(0, $callback, $default); + $this->assertSame([['zero@example.com', null]], $message->cc); + + $message = new MailMessage; + $message->unless('truthy', $callback, $default); + $this->assertSame([['truthy@example.com', null]], $message->cc); + } } diff --git a/tests/Notifications/NotificationSendQueuedNotificationTest.php b/tests/Notifications/NotificationSendQueuedNotificationTest.php index 3caa6cf266338501921102b4bb2dce7624406167..3068d8130fe41c4c7f8186d688ff7761e91c322a 100644 --- a/tests/Notifications/NotificationSendQueuedNotificationTest.php +++ b/tests/Notifications/NotificationSendQueuedNotificationTest.php @@ -2,8 +2,13 @@ namespace Illuminate\Tests\Notifications; +use Illuminate\Contracts\Database\ModelIdentifier; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Notifications\ChannelManager; +use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\SendQueuedNotifications; +use Illuminate\Support\Collection; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -18,7 +23,41 @@ class NotificationSendQueuedNotificationTest extends TestCase { $job = new SendQueuedNotifications('notifiables', 'notification'); $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('sendNow')->once()->with('notifiables', 'notification', null); + $manager->shouldReceive('sendNow')->once()->withArgs(function ($notifiables, $notification, $channels) { + return $notifiables instanceof Collection && $notifiables->toArray() === ['notifiables'] + && $notification === 'notification' + && $channels === null; + }); $job->handle($manager); } + + public function testSerializationOfNotifiableModel() + { + $identifier = new ModelIdentifier(NotifiableUser::class, [null], [], null); + $serializedIdentifier = serialize($identifier); + + $job = new SendQueuedNotifications(new NotifiableUser, 'notification'); + $serialized = serialize($job); + + $this->assertStringContainsString($serializedIdentifier, $serialized); + } + + public function testSerializationOfNormalNotifiable() + { + $notifiable = new AnonymousNotifiable; + $serializedNotifiable = serialize($notifiable); + + $job = new SendQueuedNotifications($notifiable, 'notification'); + $serialized = serialize($job); + + $this->assertStringContainsString($serializedNotifiable, $serialized); + } +} + +class NotifiableUser extends Model +{ + use Notifiable; + + public $table = 'users'; + public $timestamps = false; } diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 6c5a6aaf849df397158687fe8740980d2e0f6b2a..5c8674a45db8fb40766540651ad066b7a1977146 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -33,7 +33,7 @@ class NotificationSenderTest extends TestCase $sender = new NotificationSender($manager, $bus, $events); - $sender->send($notifiable, new DummyQueuedNotificationWithStringVia()); + $sender->send($notifiable, new DummyQueuedNotificationWithStringVia); } public function testItCanSendNotificationsWithAnEmptyStringVia() @@ -46,7 +46,7 @@ class NotificationSenderTest extends TestCase $sender = new NotificationSender($manager, $bus, $events); - $sender->sendNow($notifiable, new DummyNotificationWithEmptyStringVia()); + $sender->sendNow($notifiable, new DummyNotificationWithEmptyStringVia); } public function testItCannotSendNotificationsViaDatabaseForAnonymousNotifiables() @@ -59,7 +59,7 @@ class NotificationSenderTest extends TestCase $sender = new NotificationSender($manager, $bus, $events); - $sender->sendNow($notifiable, new DummyNotificationWithDatabaseVia()); + $sender->sendNow($notifiable, new DummyNotificationWithDatabaseVia); } } diff --git a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6e6722f1edd44f7cfb3da5e64ea27bbb69d50e95 --- /dev/null +++ b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Illuminate\Tests\Pagination; + +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\AbstractCursorPaginator; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class CursorPaginatorLoadMorphCountTest extends TestCase +{ + public function testCollectionLoadMorphCountCanChainOnThePaginator() + { + $relations = [ + 'App\\User' => 'photos', + 'App\\Company' => ['employees', 'calendars'], + ]; + + $items = m::mock(Collection::class); + $items->shouldReceive('loadMorphCount')->once()->with('parentable', $relations); + + $p = (new class extends AbstractCursorPaginator + { + // + })->setCollection($items); + + $this->assertSame($p, $p->loadMorphCount('parentable', $relations)); + } +} diff --git a/tests/Pagination/CursorPaginatorLoadMorphTest.php b/tests/Pagination/CursorPaginatorLoadMorphTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b127f21f2cd7d5a57e6c92facc8a0f68b28dfec7 --- /dev/null +++ b/tests/Pagination/CursorPaginatorLoadMorphTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Illuminate\Tests\Pagination; + +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\AbstractCursorPaginator; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class CursorPaginatorLoadMorphTest extends TestCase +{ + public function testCollectionLoadMorphCanChainOnThePaginator() + { + $relations = [ + 'App\\User' => 'photos', + 'App\\Company' => ['employees', 'calendars'], + ]; + + $items = m::mock(Collection::class); + $items->shouldReceive('loadMorph')->once()->with('parentable', $relations); + + $p = (new class extends AbstractCursorPaginator + { + // + })->setCollection($items); + + $this->assertSame($p, $p->loadMorph('parentable', $relations)); + } +} diff --git a/tests/Pagination/CursorPaginatorTest.php b/tests/Pagination/CursorPaginatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..535fb5b8aa990d939293d28033c049f56d1b13b0 --- /dev/null +++ b/tests/Pagination/CursorPaginatorTest.php @@ -0,0 +1,104 @@ +<?php + +namespace Illuminate\Tests\Pagination; + +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; +use Illuminate\Support\Collection; +use PHPUnit\Framework\TestCase; + +class CursorPaginatorTest extends TestCase +{ + public function testReturnsRelevantContextInformation() + { + $p = new CursorPaginator($array = [['id' => 1], ['id' => 2], ['id' => 3]], 2, null, [ + 'parameters' => ['id'], + ]); + + $this->assertTrue($p->hasPages()); + $this->assertTrue($p->hasMorePages()); + $this->assertEquals([['id' => 1], ['id' => 2]], $p->items()); + + $pageInfo = [ + 'data' => [['id' => 1], ['id' => 2]], + 'path' => '/', + 'per_page' => 2, + 'next_page_url' => '/?cursor='.$this->getCursor(['id' => 2]), + 'prev_page_url' => null, + ]; + + $this->assertEquals($pageInfo, $p->toArray()); + } + + public function testPaginatorRemovesTrailingSlashes() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + ['path' => 'http://website.com/test/', 'parameters' => ['id']]); + + $this->assertSame('http://website.com/test?cursor='.$this->getCursor(['id' => 5]), $p->nextPageUrl()); + } + + public function testPaginatorGeneratesUrlsWithoutTrailingSlash() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame('http://website.com/test?cursor='.$this->getCursor(['id' => 5]), $p->nextPageUrl()); + } + + public function testItRetrievesThePaginatorOptions() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame($p->getOptions(), $options); + } + + public function testPaginatorReturnsPath() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame($p->path(), 'http://website.com/test'); + } + + public function testCanTransformPaginatorItems() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $p->through(function ($item) { + $item['id'] = $item['id'] + 2; + + return $item; + }); + + $this->assertInstanceOf(CursorPaginator::class, $p); + $this->assertSame([['id' => 6], ['id' => 7]], $p->items()); + } + + public function testReturnEmptyCursorWhenItemsAreEmpty() + { + $cursor = new Cursor(['id' => 25], true); + + $p = new CursorPaginator(Collection::make(), 25, $cursor, [ + 'path' => 'http://website.com/test', + 'cursorName' => 'cursor', + 'parameters' => ['id'], + ]); + + $this->assertInstanceOf(CursorPaginator::class, $p); + $this->assertSame([ + 'data' => [], + 'path' => 'http://website.com/test', + 'per_page' => 25, + 'next_page_url' => null, + 'prev_page_url' => null, + ], $p->toArray()); + } + + protected function getCursor($params, $isNext = true) + { + return (new Cursor($params, $isNext))->encode(); + } +} diff --git a/tests/Pagination/CursorTest.php b/tests/Pagination/CursorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..05c2629619b93b550abca8e954bc7f5ba095b6bb --- /dev/null +++ b/tests/Pagination/CursorTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Illuminate\Tests\Pagination; + +use Carbon\Carbon; +use Illuminate\Pagination\Cursor; +use PHPUnit\Framework\TestCase; + +class CursorTest extends TestCase +{ + public function testCanEncodeAndDecodeSuccessfully() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => Carbon::now()->toDateTimeString(), + ], true); + + $this->assertEquals($cursor, Cursor::fromEncoded($cursor->encode())); + } + + public function testCanGetParams() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => ($now = Carbon::now()->toDateTimeString()), + ], true); + + $this->assertEquals([$now, 422], $cursor->parameters(['created_at', 'id'])); + } + + public function testCanGetParam() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => ($now = Carbon::now()->toDateTimeString()), + ], true); + + $this->assertEquals($now, $cursor->parameter('created_at')); + } +} diff --git a/tests/Pagination/LengthAwarePaginatorTest.php b/tests/Pagination/LengthAwarePaginatorTest.php index 625c3ea8c39d64f9eb2ac1a5c9cdaaed5436c618..bcdc4759218b00ff85f7d957ee57d8296585ed94 100644 --- a/tests/Pagination/LengthAwarePaginatorTest.php +++ b/tests/Pagination/LengthAwarePaginatorTest.php @@ -56,6 +56,19 @@ class LengthAwarePaginatorTest extends TestCase $this->assertEmpty($paginator->items()); } + public function testLengthAwarePaginatorisOnFirstAndLastPage() + { + $paginator = new LengthAwarePaginator(['1', '2', '3', '4'], 4, 2, 2); + + $this->assertTrue($paginator->onLastPage()); + $this->assertFalse($paginator->onFirstPage()); + + $paginator = new LengthAwarePaginator(['1', '2', '3', '4'], 4, 2, 1); + + $this->assertFalse($paginator->onLastPage()); + $this->assertTrue($paginator->onFirstPage()); + } + public function testLengthAwarePaginatorCanGenerateUrls() { $this->p->setPath('http://website.com'); diff --git a/tests/Pagination/PaginatorLoadMorphCountTest.php b/tests/Pagination/PaginatorLoadMorphCountTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c6e06454e45134d49a8754d7b673d013cb907d88 --- /dev/null +++ b/tests/Pagination/PaginatorLoadMorphCountTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Illuminate\Tests\Pagination; + +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\AbstractPaginator; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class PaginatorLoadMorphCountTest extends TestCase +{ + public function testCollectionLoadMorphCountCanChainOnThePaginator() + { + $relations = [ + 'App\\User' => 'photos', + 'App\\Company' => ['employees', 'calendars'], + ]; + + $items = m::mock(Collection::class); + $items->shouldReceive('loadMorphCount')->once()->with('parentable', $relations); + + $p = (new class extends AbstractPaginator + { + // + })->setCollection($items); + + $this->assertSame($p, $p->loadMorphCount('parentable', $relations)); + } +} diff --git a/tests/Pagination/PaginatorLoadMorphTest.php b/tests/Pagination/PaginatorLoadMorphTest.php index 5fd611040fa8628426d8b2ba95f337bc92e25aac..9ad43219daf5e0279c4db7692a933611f59a8b31 100644 --- a/tests/Pagination/PaginatorLoadMorphTest.php +++ b/tests/Pagination/PaginatorLoadMorphTest.php @@ -19,7 +19,8 @@ class PaginatorLoadMorphTest extends TestCase $items = m::mock(Collection::class); $items->shouldReceive('loadMorph')->once()->with('parentable', $relations); - $p = (new class extends AbstractPaginator { + $p = (new class extends AbstractPaginator + { // })->setCollection($items); diff --git a/tests/Pagination/PaginatorTest.php b/tests/Pagination/PaginatorTest.php index af427010494d24e756a6f303a32e760487b37ea4..aa1011af510062110bded4f33da796f048b613fc 100644 --- a/tests/Pagination/PaginatorTest.php +++ b/tests/Pagination/PaginatorTest.php @@ -62,4 +62,17 @@ class PaginatorTest extends TestCase $this->assertSame($p->path(), 'http://website.com/test'); } + + public function testCanTransformPaginatorItems() + { + $p = new Paginator($array = ['item1', 'item2', 'item3'], 3, 1, + ['path' => 'http://website.com/test']); + + $p->through(function ($item) { + return substr($item, 4, 1); + }); + + $this->assertInstanceOf(Paginator::class, $p); + $this->assertSame(['1', '2', '3'], $p->items()); + } } diff --git a/tests/Pagination/UrlWindowTest.php b/tests/Pagination/UrlWindowTest.php index 5483f2c0658af37de35fcc04177917890acbfcb5..420fa7135151119c5701fea7489d3af7e383fb11 100644 --- a/tests/Pagination/UrlWindowTest.php +++ b/tests/Pagination/UrlWindowTest.php @@ -25,35 +25,38 @@ class UrlWindowTest extends TestCase public function testPresenterCanGetAUrlRangeForAWindowOfLinks() { $array = []; - for ($i = 1; $i <= 13; $i++) { + for ($i = 1; $i <= 20; $i++) { $array[$i] = 'item'.$i; } - $p = new LengthAwarePaginator($array, count($array), 1, 7); + $p = new LengthAwarePaginator($array, count($array), 1, 12); $window = new UrlWindow($p); $slider = []; - for ($i = 4; $i <= 10; $i++) { + for ($i = 9; $i <= 15; $i++) { $slider[$i] = '/?page='.$i; } - $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => $slider, 'last' => [12 => '/?page=12', 13 => '/?page=13']], $window->get()); + $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => $slider, 'last' => [19 => '/?page=19', 20 => '/?page=20']], $window->get()); /* * Test Being Near The End Of The List */ - $p = new LengthAwarePaginator($array, count($array), 1, 8); + $array = []; + for ($i = 1; $i <= 20; $i++) { + $array[$i] = 'item'.$i; + } + $p = new LengthAwarePaginator($array, count($array), 1, 17); $window = new UrlWindow($p); $last = []; - for ($i = 5; $i <= 13; $i++) { + for ($i = 11; $i <= 20; $i++) { $last[$i] = '/?page='.$i; } - $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => null, 'last' => $last], $window->get()); } public function testCustomUrlRangeForAWindowOfLinks() { $array = []; - for ($i = 1; $i <= 13; $i++) { + for ($i = 1; $i <= 20; $i++) { $array[$i] = 'item'.$i; } @@ -66,6 +69,6 @@ class UrlWindowTest extends TestCase $slider[$i] = '/?page='.$i; } - $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => $slider, 'last' => [12 => '/?page=12', 13 => '/?page=13']], $window->get()); + $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => $slider, 'last' => [19 => '/?page=19', 20 => '/?page=20']], $window->get()); } } diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index db9d03a3c90ca7b5c56e1bba748b2f5774b91dd4..f057e2c765f693b6e28912136d51f1dad01a9822 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -28,8 +28,7 @@ class PipelineTest extends TestCase $this->assertSame('foo', $_SERVER['__test.pipe.one']); $this->assertSame('foo', $_SERVER['__test.pipe.two']); - unset($_SERVER['__test.pipe.one']); - unset($_SERVER['__test.pipe.two']); + unset($_SERVER['__test.pipe.one'], $_SERVER['__test.pipe.two']); } public function testPipelineUsageWithObjects() @@ -91,8 +90,8 @@ class PipelineTest extends TestCase ->through($function) ->thenReturn(); - $this->assertEquals('bar', $result); - $this->assertEquals('foo', $_SERVER['__test.pipe.one']); + $this->assertSame('bar', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); unset($_SERVER['__test.pipe.one']); } diff --git a/tests/Queue/DynamoDbFailedJobProviderTest.php b/tests/Queue/DynamoDbFailedJobProviderTest.php index 490812996b297027ef320d6712c2cd0aba1d2a57..8ee85988a88d3ef5d81c3f63326690c065cda9b0 100644 --- a/tests/Queue/DynamoDbFailedJobProviderTest.php +++ b/tests/Queue/DynamoDbFailedJobProviderTest.php @@ -40,7 +40,7 @@ class DynamoDbFailedJobProviderTest extends TestCase 'uuid' => ['S' => (string) $uuid], 'connection' => ['S' => 'connection'], 'queue' => ['S' => 'queue'], - 'payload' => ['S' => 'payload'], + 'payload' => ['S' => json_encode(['uuid' => (string) $uuid])], 'exception' => ['S' => (string) $exception], 'failed_at' => ['N' => (string) $now->getTimestamp()], 'expires_at' => ['N' => (string) $now->addDays(3)->getTimestamp()], @@ -49,7 +49,7 @@ class DynamoDbFailedJobProviderTest extends TestCase $provider = new DynamoDbFailedJobProvider($dynamoDbClient, 'application', 'table'); - $provider->log('connection', 'queue', 'payload', $exception); + $provider->log('connection', 'queue', json_encode(['uuid' => (string) $uuid]), $exception); Str::createUuidsNormally(); } @@ -144,8 +144,6 @@ class DynamoDbFailedJobProviderTest extends TestCase { $dynamoDbClient = m::mock(DynamoDbClient::class); - $time = time(); - $dynamoDbClient->shouldReceive('getItem')->once()->with([ 'TableName' => 'table', 'Key' => [ @@ -165,8 +163,6 @@ class DynamoDbFailedJobProviderTest extends TestCase { $dynamoDbClient = m::mock(DynamoDbClient::class); - $time = time(); - $dynamoDbClient->shouldReceive('deleteItem')->once()->with([ 'TableName' => 'table', 'Key' => [ diff --git a/tests/Queue/QueueBeanstalkdJobTest.php b/tests/Queue/QueueBeanstalkdJobTest.php index eb56bf322885e3e329ee4a1f5912db19244a6fa3..ebea82a7de3871ac066a47a38dc1d33c9443d79b 100755 --- a/tests/Queue/QueueBeanstalkdJobTest.php +++ b/tests/Queue/QueueBeanstalkdJobTest.php @@ -33,10 +33,10 @@ class QueueBeanstalkdJobTest extends TestCase public function testFailProperlyCallsTheJobHandler() { $job = $this->getJob(); - $job->getPheanstalkJob()->shouldReceive('getData')->once()->andReturn(json_encode(['job' => 'foo', 'data' => ['data']])); + $job->getPheanstalkJob()->shouldReceive('getData')->once()->andReturn(json_encode(['job' => 'foo', 'uuid' => 'test-uuid', 'data' => ['data']])); $job->getContainer()->shouldReceive('make')->once()->with('foo')->andReturn($handler = m::mock(BeanstalkdJobTestFailedTest::class)); $job->getPheanstalk()->shouldReceive('delete')->once()->with($job->getPheanstalkJob())->andReturnSelf(); - $handler->shouldReceive('failed')->once()->with(['data'], m::type(Exception::class)); + $handler->shouldReceive('failed')->once()->with(['data'], m::type(Exception::class), 'test-uuid'); $job->getContainer()->shouldReceive('make')->once()->with(Dispatcher::class)->andReturn($events = m::mock(Dispatcher::class)); $events->shouldReceive('dispatch')->once()->with(m::type(JobFailed::class))->andReturnNull(); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index fb2846fc0b241e433bd8cde7146343b1a8b65d11..02195bc58ff5f6e669ad93d19bf974de55e1ebbc 100755 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Queue; use Illuminate\Container\Container; use Illuminate\Queue\BeanstalkdQueue; use Illuminate\Queue\Jobs\BeanstalkdJob; +use Illuminate\Support\Str; use Mockery as m; use Pheanstalk\Job; use Pheanstalk\Pheanstalk; @@ -12,6 +13,16 @@ use PHPUnit\Framework\TestCase; class QueueBeanstalkdQueueTest extends TestCase { + /** + * @var BeanstalkdQueue + */ + private $queue; + + /** + * @var Container|m\LegacyMockInterface|m\MockInterface + */ + private $container; + protected function tearDown(): void { m::close(); @@ -19,63 +30,96 @@ class QueueBeanstalkdQueueTest extends TestCase public function testPushProperlyPushesJobOntoBeanstalkd() { - $queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), 'default', 60); - $pheanstalk = $queue->getPheanstalk(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $this->setQueue('default', 60); + $pheanstalk = $this->queue->getPheanstalk(); $pheanstalk->shouldReceive('useTube')->once()->with('stack')->andReturn($pheanstalk); $pheanstalk->shouldReceive('useTube')->once()->with('default')->andReturn($pheanstalk); - $pheanstalk->shouldReceive('put')->twice()->with(json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), 1024, 0, 60); + $pheanstalk->shouldReceive('put')->twice()->with(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), 1024, 0, 60); - $queue->push('foo', ['data'], 'stack'); - $queue->push('foo', ['data']); + $this->queue->push('foo', ['data'], 'stack'); + $this->queue->push('foo', ['data']); + + $this->container->shouldHaveReceived('bound')->with('events')->times(2); + + Str::createUuidsNormally(); } public function testDelayedPushProperlyPushesJobOntoBeanstalkd() { - $queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), 'default', 60); - $pheanstalk = $queue->getPheanstalk(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $this->setQueue('default', 60); + $pheanstalk = $this->queue->getPheanstalk(); $pheanstalk->shouldReceive('useTube')->once()->with('stack')->andReturn($pheanstalk); $pheanstalk->shouldReceive('useTube')->once()->with('default')->andReturn($pheanstalk); - $pheanstalk->shouldReceive('put')->twice()->with(json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), Pheanstalk::DEFAULT_PRIORITY, 5, Pheanstalk::DEFAULT_TTR); + $pheanstalk->shouldReceive('put')->twice()->with(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), Pheanstalk::DEFAULT_PRIORITY, 5, Pheanstalk::DEFAULT_TTR); - $queue->later(5, 'foo', ['data'], 'stack'); - $queue->later(5, 'foo', ['data']); + $this->queue->later(5, 'foo', ['data'], 'stack'); + $this->queue->later(5, 'foo', ['data']); + + $this->container->shouldHaveReceived('bound')->with('events')->times(2); + + Str::createUuidsNormally(); } public function testPopProperlyPopsJobOffOfBeanstalkd() { - $queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), 'default', 60); - $queue->setContainer(m::mock(Container::class)); - $pheanstalk = $queue->getPheanstalk(); + $this->setQueue('default', 60); + + $pheanstalk = $this->queue->getPheanstalk(); $pheanstalk->shouldReceive('watchOnly')->once()->with('default')->andReturn($pheanstalk); $job = m::mock(Job::class); $pheanstalk->shouldReceive('reserveWithTimeout')->once()->with(0)->andReturn($job); - $result = $queue->pop(); + $result = $this->queue->pop(); $this->assertInstanceOf(BeanstalkdJob::class, $result); } public function testBlockingPopProperlyPopsJobOffOfBeanstalkd() { - $queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), 'default', 60, 60); - $queue->setContainer(m::mock(Container::class)); - $pheanstalk = $queue->getPheanstalk(); + $this->setQueue('default', 60, 60); + + $pheanstalk = $this->queue->getPheanstalk(); $pheanstalk->shouldReceive('watchOnly')->once()->with('default')->andReturn($pheanstalk); $job = m::mock(Job::class); $pheanstalk->shouldReceive('reserveWithTimeout')->once()->with(60)->andReturn($job); - $result = $queue->pop(); + $result = $this->queue->pop(); $this->assertInstanceOf(BeanstalkdJob::class, $result); } public function testDeleteProperlyRemoveJobsOffBeanstalkd() { - $queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), 'default', 60); - $pheanstalk = $queue->getPheanstalk(); + $this->setQueue('default', 60); + + $pheanstalk = $this->queue->getPheanstalk(); $pheanstalk->shouldReceive('useTube')->once()->with('default')->andReturn($pheanstalk); $pheanstalk->shouldReceive('delete')->once()->with(m::type(Job::class)); - $queue->deleteMessage('default', 1); + $this->queue->deleteMessage('default', 1); + } + + /** + * @param string $default + * @param int $timeToRun + * @param int $blockFor + */ + private function setQueue($default, $timeToRun, $blockFor = 0) + { + $this->queue = new BeanstalkdQueue(m::mock(Pheanstalk::class), $default, $timeToRun, $blockFor); + $this->container = m::spy(Container::class); + $this->queue->setContainer($this->container); } } diff --git a/tests/Queue/QueueDatabaseQueueIntegrationTest.php b/tests/Queue/QueueDatabaseQueueIntegrationTest.php index 80046f3c546635a49237b9ed7b9bf1a5b639b7de..fa1955268e2f252bb5f3bd4eb212ba68facbf62e 100644 --- a/tests/Queue/QueueDatabaseQueueIntegrationTest.php +++ b/tests/Queue/QueueDatabaseQueueIntegrationTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; class QueueDatabaseQueueIntegrationTest extends TestCase { /** - * @var DatabaseQueue The queue instance. + * @var \Illuminate\Queue\DatabaseQueue */ protected $queue; @@ -23,7 +23,7 @@ class QueueDatabaseQueueIntegrationTest extends TestCase protected $table; /** - * @var Container The IOC container. + * @var \Illuminate\Container\Container */ protected $container; @@ -32,8 +32,8 @@ class QueueDatabaseQueueIntegrationTest extends TestCase $db = new DB; $db->addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'sqlite', + 'database' => ':memory:', ]); $db->bootEloquent(); @@ -147,6 +147,35 @@ class QueueDatabaseQueueIntegrationTest extends TestCase $this->assertEquals(1, $popped_job->attempts(), 'The "attempts" attribute of the Job object was not updated by pop!'); } + /** + * Test that the queue can be cleared. + */ + public function testThatQueueCanBeCleared() + { + $this->connection() + ->table('jobs') + ->insert([[ + 'id' => 1, + 'queue' => $mock_queue_name = 'mock_queue_name', + 'payload' => 'mock_payload', + 'attempts' => 0, + 'reserved_at' => Carbon::now()->addDay()->getTimestamp(), + 'available_at' => Carbon::now()->subDay()->getTimestamp(), + 'created_at' => Carbon::now()->getTimestamp(), + ], [ + 'id' => 2, + 'queue' => $mock_queue_name, + 'payload' => 'mock_payload 2', + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => Carbon::now()->subSeconds(1)->getTimestamp(), + 'created_at' => Carbon::now()->getTimestamp(), + ]]); + + $this->assertEquals(2, $this->queue->clear($mock_queue_name)); + $this->assertEquals(0, $this->queue->size()); + } + /** * Test that jobs that are not reserved and have an available_at value in the future, are not popped. */ diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 0c5cbb487c949d8cdd2f580f957047869ab86129..118a38434ac1e6ad1feb12803d59a5559a14d94c 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -2,9 +2,11 @@ namespace Illuminate\Tests\Queue; +use Illuminate\Container\Container; use Illuminate\Database\Connection; use Illuminate\Queue\DatabaseQueue; use Illuminate\Queue\Queue; +use Illuminate\Support\Str; use Mockery as m; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -19,38 +21,60 @@ class QueueDatabaseQueueUnitTest extends TestCase public function testPushProperlyPushesJobOntoDatabase() { - $queue = $this->getMockBuilder(DatabaseQueue::class)->setMethods(['currentTime'])->setConstructorArgs([$database = m::mock(Connection::class), 'table', 'default'])->getMock(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $queue = $this->getMockBuilder(DatabaseQueue::class)->onlyMethods(['currentTime'])->setConstructorArgs([$database = m::mock(Connection::class), 'table', 'default'])->getMock(); $queue->expects($this->any())->method('currentTime')->willReturn('time'); + $queue->setContainer($container = m::spy(Container::class)); $database->shouldReceive('table')->with('table')->andReturn($query = m::mock(stdClass::class)); - $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { + $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) use ($uuid) { $this->assertSame('default', $array['queue']); - $this->assertEquals(json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); + $this->assertSame(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); $this->assertEquals(0, $array['attempts']); $this->assertNull($array['reserved_at']); $this->assertIsInt($array['available_at']); }); $queue->push('foo', ['data']); + + $container->shouldHaveReceived('bound')->with('events')->once(); + + Str::createUuidsNormally(); } public function testDelayedPushProperlyPushesJobOntoDatabase() { + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + $queue = $this->getMockBuilder( - DatabaseQueue::class)->setMethods( + DatabaseQueue::class)->onlyMethods( ['currentTime'])->setConstructorArgs( [$database = m::mock(Connection::class), 'table', 'default'] )->getMock(); $queue->expects($this->any())->method('currentTime')->willReturn('time'); + $queue->setContainer($container = m::spy(Container::class)); $database->shouldReceive('table')->with('table')->andReturn($query = m::mock(stdClass::class)); - $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { + $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) use ($uuid) { $this->assertSame('default', $array['queue']); - $this->assertEquals(json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); + $this->assertSame(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); $this->assertEquals(0, $array['attempts']); $this->assertNull($array['reserved_at']); $this->assertIsInt($array['available_at']); }); $queue->later(10, 'foo', ['data']); + + $container->shouldHaveReceived('bound')->with('events')->once(); + + Str::createUuidsNormally(); } public function testFailureToCreatePayloadFromObject() @@ -88,22 +112,28 @@ class QueueDatabaseQueueUnitTest extends TestCase public function testBulkBatchPushesOntoDatabase() { + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + $database = m::mock(Connection::class); - $queue = $this->getMockBuilder(DatabaseQueue::class)->setMethods(['currentTime', 'availableAt'])->setConstructorArgs([$database, 'table', 'default'])->getMock(); + $queue = $this->getMockBuilder(DatabaseQueue::class)->onlyMethods(['currentTime', 'availableAt'])->setConstructorArgs([$database, 'table', 'default'])->getMock(); $queue->expects($this->any())->method('currentTime')->willReturn('created'); $queue->expects($this->any())->method('availableAt')->willReturn('available'); $database->shouldReceive('table')->with('table')->andReturn($query = m::mock(stdClass::class)); - $query->shouldReceive('insert')->once()->andReturnUsing(function ($records) { + $query->shouldReceive('insert')->once()->andReturnUsing(function ($records) use ($uuid) { $this->assertEquals([[ 'queue' => 'queue', - 'payload' => json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), + 'payload' => json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), 'attempts' => 0, 'reserved_at' => null, 'available_at' => 'available', 'created_at' => 'created', ], [ 'queue' => 'queue', - 'payload' => json_encode(['displayName' => 'bar', 'job' => 'bar', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data']]), + 'payload' => json_encode(['uuid' => $uuid, 'displayName' => 'bar', 'job' => 'bar', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data']]), 'attempts' => 0, 'reserved_at' => null, 'available_at' => 'available', @@ -112,6 +142,8 @@ class QueueDatabaseQueueUnitTest extends TestCase }); $queue->bulk(['foo', 'bar'], ['data'], 'queue'); + + Str::createUuidsNormally(); } public function testBuildDatabaseRecordWithPayloadAtTheEnd() diff --git a/tests/Queue/QueueListenerTest.php b/tests/Queue/QueueListenerTest.php index 54d982b84ca4d1ec2ddab3801440a287ad2f1d24..2df27a47609a5f46a8e9f55ac9986fb030ca12d3 100755 --- a/tests/Queue/QueueListenerTest.php +++ b/tests/Queue/QueueListenerTest.php @@ -40,7 +40,7 @@ class QueueListenerTest extends TestCase { $listener = new Listener(__DIR__); $options = new ListenerOptions; - $options->delay = 1; + $options->backoff = 1; $options->memory = 2; $options->timeout = 3; $process = $listener->makeProcess('connection', 'queue', $options); @@ -49,14 +49,14 @@ class QueueListenerTest extends TestCase $this->assertInstanceOf(Process::class, $process); $this->assertEquals(__DIR__, $process->getWorkingDirectory()); $this->assertEquals(3, $process->getTimeout()); - $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine()); + $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine()); } public function testMakeProcessCorrectlyFormatsCommandLineWithAnEnvironmentSpecified() { $listener = new Listener(__DIR__); - $options = new ListenerOptions('test'); - $options->delay = 1; + $options = new ListenerOptions('default', 'test'); + $options->backoff = 1; $options->memory = 2; $options->timeout = 3; $process = $listener->makeProcess('connection', 'queue', $options); @@ -65,14 +65,14 @@ class QueueListenerTest extends TestCase $this->assertInstanceOf(Process::class, $process); $this->assertEquals(__DIR__, $process->getWorkingDirectory()); $this->assertEquals(3, $process->getTimeout()); - $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape} {$escape}--env=test{$escape}", $process->getCommandLine()); + $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape} {$escape}--env=test{$escape}", $process->getCommandLine()); } public function testMakeProcessCorrectlyFormatsCommandLineWhenTheConnectionIsNotSpecified() { $listener = new Listener(__DIR__); - $options = new ListenerOptions('test'); - $options->delay = 1; + $options = new ListenerOptions('default', 'test'); + $options->backoff = 1; $options->memory = 2; $options->timeout = 3; $process = $listener->makeProcess(null, 'queue', $options); @@ -81,6 +81,6 @@ class QueueListenerTest extends TestCase $this->assertInstanceOf(Process::class, $process); $this->assertEquals(__DIR__, $process->getWorkingDirectory()); $this->assertEquals(3, $process->getTimeout()); - $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape} {$escape}--env=test{$escape}", $process->getCommandLine()); + $this->assertEquals($escape.PHP_BINARY.$escape." {$escape}artisan{$escape} {$escape}queue:work{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape} {$escape}--env=test{$escape}", $process->getCommandLine()); } } diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 2c7046a35b8df8e169b3740f2e11c31cf1fc265c..442676de71ceea9c26c853e53ca8b566d533afdb 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -2,11 +2,13 @@ namespace Illuminate\Tests\Queue; +use Illuminate\Container\Container; use Illuminate\Contracts\Redis\Factory; use Illuminate\Queue\LuaScripts; use Illuminate\Queue\Queue; use Illuminate\Queue\RedisQueue; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -19,21 +21,38 @@ class QueueRedisQueueTest extends TestCase public function testPushProperlyPushesJobOntoRedis() { - $queue = $this->getMockBuilder(RedisQueue::class)->setMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); + $queue->setContainer($container = m::spy(Container::class)); $redis->shouldReceive('connection')->once()->andReturn($redis); - $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0])); + $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0])); $id = $queue->push('foo', ['data']); $this->assertSame('foo', $id); + $container->shouldHaveReceived('bound')->with('events')->once(); + + Str::createUuidsNormally(); } public function testPushProperlyPushesJobOntoRedisWithCustomPayloadHook() { - $queue = $this->getMockBuilder(RedisQueue::class)->setMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); + $queue->setContainer($container = m::spy(Container::class)); $redis->shouldReceive('connection')->once()->andReturn($redis); - $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'id' => 'foo', 'attempts' => 0])); + $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'id' => 'foo', 'attempts' => 0])); Queue::createPayloadUsing(function ($connection, $queue, $payload) { return ['custom' => 'taylor']; @@ -41,16 +60,26 @@ class QueueRedisQueueTest extends TestCase $id = $queue->push('foo', ['data']); $this->assertSame('foo', $id); + $container->shouldHaveReceived('bound')->with('events')->once(); Queue::createPayloadUsing(null); + + Str::createUuidsNormally(); } public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook() { - $queue = $this->getMockBuilder(RedisQueue::class)->setMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); + $queue->setContainer($container = m::spy(Container::class)); $redis->shouldReceive('connection')->once()->andReturn($redis); - $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'bar' => 'foo', 'id' => 'foo', 'attempts' => 0])); + $redis->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'bar' => 'foo', 'id' => 'foo', 'attempts' => 0])); Queue::createPayloadUsing(function ($connection, $queue, $payload) { return ['custom' => 'taylor']; @@ -62,13 +91,23 @@ class QueueRedisQueueTest extends TestCase $id = $queue->push('foo', ['data']); $this->assertSame('foo', $id); + $container->shouldHaveReceived('bound')->with('events')->once(); Queue::createPayloadUsing(null); + + Str::createUuidsNormally(); } public function testDelayedPushProperlyPushesJobOntoRedis() { - $queue = $this->getMockBuilder(RedisQueue::class)->setMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); $queue->expects($this->once())->method('availableAt')->with(1)->willReturn(2); @@ -76,17 +115,27 @@ class QueueRedisQueueTest extends TestCase $redis->shouldReceive('zadd')->once()->with( 'queues:default:delayed', 2, - json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0]) + json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0]) ); $id = $queue->later(1, 'foo', ['data']); $this->assertSame('foo', $id); + $container->shouldHaveReceived('bound')->with('events')->once(); + + Str::createUuidsNormally(); } public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis() { + $uuid = Str::uuid(); + + Str::createUuidsUsing(function () use ($uuid) { + return $uuid; + }); + $date = Carbon::now(); - $queue = $this->getMockBuilder(RedisQueue::class)->setMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Factory::class), 'default'])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); $queue->expects($this->once())->method('availableAt')->with($date)->willReturn(2); @@ -94,9 +143,12 @@ class QueueRedisQueueTest extends TestCase $redis->shouldReceive('zadd')->once()->with( 'queues:default:delayed', 2, - json_encode(['displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'delay' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0]) + json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0]) ); $queue->later($date, 'foo', ['data']); + $container->shouldHaveReceived('bound')->with('events')->once(); + + Str::createUuidsNormally(); } } diff --git a/tests/Queue/QueueSizeTest.php b/tests/Queue/QueueSizeTest.php index b5ff492248ee13aefbe68e82708bfab7841f86df..b0c916338aa7f8d3654069d034b08ad468353d89 100644 --- a/tests/Queue/QueueSizeTest.php +++ b/tests/Queue/QueueSizeTest.php @@ -1,6 +1,6 @@ <?php -namespace Illuminate\Tests\Integration\Queue; +namespace Illuminate\Tests\Queue; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index 135345080d0c801a49800a2d05ca9bd1ca5749ca..105d58749d37297682212a475e06cb9ab666eb38 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -28,7 +28,7 @@ class QueueSqsJobTest extends TestCase // Get a mock of the SqsClient $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->setMethods(['deleteMessage']) + ->addMethods(['deleteMessage']) ->disableOriginalConstructor() ->getMock(); @@ -66,10 +66,10 @@ class QueueSqsJobTest extends TestCase public function testDeleteRemovesTheJobFromSqs() { $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->setMethods(['deleteMessage']) + ->addMethods(['deleteMessage']) ->disableOriginalConstructor() ->getMock(); - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); $queue->setContainer($this->mockedContainer); $job = $this->getJob(); $job->getSqs()->expects($this->once())->method('deleteMessage')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle]); @@ -79,10 +79,10 @@ class QueueSqsJobTest extends TestCase public function testReleaseProperlyReleasesTheJobOntoSqs() { $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->setMethods(['changeMessageVisibility']) + ->addMethods(['changeMessageVisibility']) ->disableOriginalConstructor() ->getMock(); - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); $queue->setContainer($this->mockedContainer); $job = $this->getJob(); $job->getSqs()->expects($this->once())->method('changeMessageVisibility')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle, 'VisibilityTimeout' => $this->releaseDelay]); diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 953f3b1078fe5eb86bad967579d1d2027a33410d..3c1ac3a730865a06111b20f6e9a0ec1cba907a93 100755 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -70,7 +70,7 @@ class QueueSqsQueueTest extends TestCase public function testPopProperlyPopsJobOffOfSqs() { - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); $queue->setContainer(m::mock(Container::class)); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('receiveMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'AttributeNames' => ['ApproximateReceiveCount']])->andReturn($this->mockedReceiveMessageResponseModel); @@ -80,7 +80,7 @@ class QueueSqsQueueTest extends TestCase public function testPopProperlyHandlesEmptyMessage() { - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); $queue->setContainer(m::mock(Container::class)); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('receiveMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'AttributeNames' => ['ApproximateReceiveCount']])->andReturn($this->mockedReceiveEmptyMessageResponseModel); @@ -91,43 +91,49 @@ class QueueSqsQueueTest extends TestCase public function testDelayedPushWithDateTimeProperlyPushesJobOntoSqs() { $now = Carbon::now(); - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($now)->willReturn(5); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload, 'DelaySeconds' => 5])->andReturn($this->mockedSendMessageResponseModel); $id = $queue->later($now->addSeconds(5), $this->mockedJob, $this->mockedData, $this->queueName); $this->assertEquals($this->mockedMessageId, $id); + $container->shouldHaveReceived('bound')->with('events')->once(); } public function testDelayedPushProperlyPushesJobOntoSqs() { - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload, 'DelaySeconds' => $this->mockedDelay])->andReturn($this->mockedSendMessageResponseModel); $id = $queue->later($this->mockedDelay, $this->mockedJob, $this->mockedData, $this->queueName); $this->assertEquals($this->mockedMessageId, $id); + $container->shouldHaveReceived('bound')->with('events')->once(); } public function testPushProperlyPushesJobOntoSqs() { - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload])->andReturn($this->mockedSendMessageResponseModel); $id = $queue->push($this->mockedJob, $this->mockedData, $this->queueName); $this->assertEquals($this->mockedMessageId, $id); + $container->shouldHaveReceived('bound')->with('events')->once(); } public function testSizeProperlyReadsSqsQueueSize() { - $queue = $this->getMockBuilder(SqsQueue::class)->setMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); + $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('getQueueAttributes')->once()->with(['QueueUrl' => $this->queueUrl, 'AttributeNames' => ['ApproximateNumberOfMessages']])->andReturn($this->mockedQueueAttributesResponseModel); $size = $queue->size($this->queueName); - $this->assertEquals($size, 1); + $this->assertEquals(1, $size); } public function testGetQueueProperlyResolvesUrlWithPrefix() @@ -138,6 +144,16 @@ class QueueSqsQueueTest extends TestCase $this->assertEquals($queueUrl, $queue->getQueue('test')); } + public function testGetQueueProperlyResolvesFifoUrlWithPrefix() + { + $this->queueName = 'emails.fifo'; + $this->queueUrl = $this->prefix.$this->queueName; + $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix); + $this->assertEquals($this->queueUrl, $queue->getQueue(null)); + $queueUrl = $this->baseUrl.'/'.$this->account.'/test.fifo'; + $this->assertEquals($queueUrl, $queue->getQueue('test.fifo')); + } + public function testGetQueueProperlyResolvesUrlWithoutPrefix() { $queue = new SqsQueue($this->sqs, $this->queueUrl); @@ -145,4 +161,47 @@ class QueueSqsQueueTest extends TestCase $queueUrl = $this->baseUrl.'/'.$this->account.'/test'; $this->assertEquals($queueUrl, $queue->getQueue($queueUrl)); } + + public function testGetQueueProperlyResolvesFifoUrlWithoutPrefix() + { + $this->queueName = 'emails.fifo'; + $this->queueUrl = $this->prefix.$this->queueName; + $queue = new SqsQueue($this->sqs, $this->queueUrl); + $this->assertEquals($this->queueUrl, $queue->getQueue(null)); + $fifoQueueUrl = $this->baseUrl.'/'.$this->account.'/test.fifo'; + $this->assertEquals($fifoQueueUrl, $queue->getQueue($fifoQueueUrl)); + } + + public function testGetQueueProperlyResolvesUrlWithSuffix() + { + $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix, $suffix = '-staging'); + $this->assertEquals($this->queueUrl.$suffix, $queue->getQueue(null)); + $queueUrl = $this->baseUrl.'/'.$this->account.'/test'.$suffix; + $this->assertEquals($queueUrl, $queue->getQueue('test')); + } + + public function testGetQueueProperlyResolvesFifoUrlWithSuffix() + { + $this->queueName = 'emails.fifo'; + $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix, $suffix = '-staging'); + $this->assertEquals("{$this->prefix}emails-staging.fifo", $queue->getQueue(null)); + $queueUrl = $this->baseUrl.'/'.$this->account.'/test'.$suffix.'.fifo'; + $this->assertEquals($queueUrl, $queue->getQueue('test.fifo')); + } + + public function testGetQueueEnsuresTheQueueIsOnlySuffixedOnce() + { + $queue = new SqsQueue($this->sqs, "{$this->queueName}-staging", $this->prefix, $suffix = '-staging'); + $this->assertEquals($this->queueUrl.$suffix, $queue->getQueue(null)); + $queueUrl = $this->baseUrl.'/'.$this->account.'/test'.$suffix; + $this->assertEquals($queueUrl, $queue->getQueue('test-staging')); + } + + public function testGetFifoQueueEnsuresTheQueueIsOnlySuffixedOnce() + { + $queue = new SqsQueue($this->sqs, "{$this->queueName}-staging.fifo", $this->prefix, $suffix = '-staging'); + $this->assertEquals("{$this->prefix}{$this->queueName}{$suffix}.fifo", $queue->getQueue(null)); + $queueUrl = $this->baseUrl.'/'.$this->account.'/test'.$suffix.'.fifo'; + $this->assertEquals($queueUrl, $queue->getQueue('test-staging.fifo')); + } } diff --git a/tests/Queue/QueueSyncQueueTest.php b/tests/Queue/QueueSyncQueueTest.php index 2064add943a8ca96b99c8338b0fc8c12e7ec4292..d4d07bbb2d6a7213b4ad232f2ad84c4cf78da498 100755 --- a/tests/Queue/QueueSyncQueueTest.php +++ b/tests/Queue/QueueSyncQueueTest.php @@ -6,8 +6,11 @@ use Exception; use Illuminate\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\QueueableEntity; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Jobs\SyncJob; use Illuminate\Queue\SyncQueue; +use LogicException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -54,6 +57,26 @@ class QueueSyncQueueTest extends TestCase Container::setInstance(); } + + public function testCreatesPayloadObject() + { + $sync = new SyncQueue; + $container = new Container; + $container->bind(\Illuminate\Contracts\Events\Dispatcher::class, \Illuminate\Events\Dispatcher::class); + $container->bind(\Illuminate\Contracts\Bus\Dispatcher::class, \Illuminate\Bus\Dispatcher::class); + $container->bind(\Illuminate\Contracts\Container\Container::class, \Illuminate\Container\Container::class); + $sync->setContainer($container); + + SyncQueue::createPayloadUsing(function ($connection, $queue, $payload) { + return ['data' => ['extra' => 'extraValue']]; + }); + + try { + $sync->push(new SyncQueueJob()); + } catch (LogicException $e) { + $this->assertEquals('extraValue', $e->getMessage()); + } + } } class SyncQueueTestEntity implements QueueableEntity @@ -94,3 +117,20 @@ class FailingSyncQueueTestHandler $_SERVER['__sync.failed'] = true; } } + +class SyncQueueJob implements ShouldQueue +{ + use InteractsWithQueue; + + public function handle() + { + throw new LogicException($this->getValueFromJob('extra')); + } + + public function getValueFromJob($key) + { + $payload = $this->job->payload(); + + return $payload['data'][$key] ?? null; + } +} diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index d084ca1e3f4ae8a4c39eeea1d2ccf99f6617ff9a..28564c3c7720813197fdbfa54c959291182e8e49 100755 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -59,21 +59,36 @@ class QueueWorkerTest extends TestCase $secondJob = new WorkerFakeJob, ]]); - try { - $worker->daemon('default', 'queue', $workerOptions); + $status = $worker->daemon('default', 'queue', $workerOptions); - $this->fail('Expected LoopBreakerException to be thrown.'); - } catch (LoopBreakerException $e) { - $this->assertTrue($firstJob->fired); + $this->assertTrue($secondJob->fired); - $this->assertTrue($secondJob->fired); + $this->assertSame(0, $status); - $this->assertSame(0, $worker->stoppedWithStatus); + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessing::class))->twice(); - $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessing::class))->twice(); + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->twice(); + } - $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->twice(); - } + public function testWorkerStopsWhenMemoryExceeded() + { + $workerOptions = new WorkerOptions; + + $worker = $this->getWorker('default', ['queue' => [ + $firstJob = new WorkerFakeJob, + $secondJob = new WorkerFakeJob, + ]]); + $worker->stopOnMemoryExceeded = true; + + $status = $worker->daemon('default', 'queue', $workerOptions); + + $this->assertTrue($firstJob->fired); + $this->assertFalse($secondJob->fired); + $this->assertSame(12, $status); + + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessing::class))->once(); + + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->once(); } public function testJobCanBeFiredBasedOnPriority() @@ -127,7 +142,7 @@ class QueueWorkerTest extends TestCase }); $worker = $this->getWorker('default', ['queue' => [$job]]); - $worker->runNextJob('default', 'queue', $this->workerOptions(['delay' => 10])); + $worker->runNextJob('default', 'queue', $this->workerOptions(['backoff' => 10])); $this->assertEquals(10, $job->releaseAfter); $this->assertFalse($job->deleted); @@ -170,7 +185,7 @@ class QueueWorkerTest extends TestCase throw $e; }); - $job->timeoutAt = now()->addSeconds(1)->getTimestamp(); + $job->retryUntil = now()->addSeconds(1)->getTimestamp(); $job->attempts = 0; @@ -214,7 +229,7 @@ class QueueWorkerTest extends TestCase $job->attempts++; }); - $job->timeoutAt = Carbon::now()->addSeconds(2)->getTimestamp(); + $job->retryUntil = Carbon::now()->addSeconds(2)->getTimestamp(); $job->attempts = 1; @@ -256,10 +271,10 @@ class QueueWorkerTest extends TestCase }); $job->attempts = 1; - $job->delaySeconds = 10; + $job->backoff = 10; $worker = $this->getWorker('default', ['queue' => [$job]]); - $worker->runNextJob('default', 'queue', $this->workerOptions(['delay' => 3, 'maxTries' => 0])); + $worker->runNextJob('default', 'queue', $this->workerOptions(['backoff' => 3, 'maxTries' => 0])); $this->assertEquals(10, $job->releaseAfter); } @@ -313,6 +328,37 @@ class QueueWorkerTest extends TestCase $this->assertTrue($job->isDeleted()); } + public function testWorkerPicksJobUsingCustomCallbacks() + { + $worker = $this->getWorker('default', [ + 'default' => [$defaultJob = new WorkerFakeJob], 'custom' => [$customJob = new WorkerFakeJob], + ]); + + $worker->runNextJob('default', 'default', new WorkerOptions); + $worker->runNextJob('default', 'default', new WorkerOptions); + + $this->assertTrue($defaultJob->fired); + $this->assertFalse($customJob->fired); + + $worker2 = $this->getWorker('default', [ + 'default' => [$defaultJob = new WorkerFakeJob], 'custom' => [$customJob = new WorkerFakeJob], + ]); + + $worker2->setName('myworker'); + + Worker::popUsing('myworker', function ($pop) { + return $pop('custom'); + }); + + $worker2->runNextJob('default', 'default', new WorkerOptions); + $worker2->runNextJob('default', 'default', new WorkerOptions); + + $this->assertFalse($defaultJob->fired); + $this->assertTrue($customJob->fired); + + Worker::popUsing('myworker', null); + } + /** * Helpers... */ @@ -362,9 +408,7 @@ class InsomniacWorker extends Worker public function stop($status = 0) { - $this->stoppedWithStatus = $status; - - throw new LoopBreakerException; + return $status; } public function daemonShouldRun(WorkerOptions $options, $connectionName, $queue) @@ -432,8 +476,11 @@ class WorkerFakeJob implements QueueJobContract public $releaseAfter; public $released = false; public $maxTries; - public $delaySeconds; - public $timeoutAt; + public $maxExceptions; + public $shouldFailOnTimeout = false; + public $uuid; + public $backoff; + public $retryUntil; public $attempts = 0; public $failedWith; public $failed = false; @@ -469,14 +516,29 @@ class WorkerFakeJob implements QueueJobContract return $this->maxTries; } - public function delaySeconds() + public function maxExceptions() + { + return $this->maxExceptions; + } + + public function shouldFailOnTimeout() + { + return $this->shouldFailOnTimeout; + } + + public function uuid() + { + return $this->uuid; + } + + public function backoff() { - return $this->delaySeconds; + return $this->backoff; } - public function timeoutAt() + public function retryUntil() { - return $this->timeoutAt; + return $this->retryUntil; } public function delete() diff --git a/tests/Queue/RedisQueueIntegrationTest.php b/tests/Queue/RedisQueueIntegrationTest.php index e023fe49b1041980f3c31958aa39ccf7861d71a4..18f54349205201e2569ad6d530163ecdad46d507 100644 --- a/tests/Queue/RedisQueueIntegrationTest.php +++ b/tests/Queue/RedisQueueIntegrationTest.php @@ -3,11 +3,14 @@ namespace Illuminate\Tests\Queue; use Illuminate\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; +use Illuminate\Queue\Events\JobQueued; use Illuminate\Queue\Jobs\RedisJob; use Illuminate\Queue\RedisQueue; use Illuminate\Support\Carbon; use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Str; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -16,10 +19,15 @@ class RedisQueueIntegrationTest extends TestCase use InteractsWithRedis, InteractsWithTime; /** - * @var RedisQueue + * @var \Illuminate\Queue\RedisQueue */ private $queue; + /** + * @var \Mockery\MockInterface|\Mockery\LegacyMockInterface + */ + private $container; + protected function setUp(): void { Carbon::setTestNow(Carbon::now()); @@ -38,7 +46,7 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testExpiredJobsArePopped($driver) { @@ -56,6 +64,8 @@ class RedisQueueIntegrationTest extends TestCase $this->queue->later(-300, $jobs[2]); $this->queue->later(-100, $jobs[3]); + $this->container->shouldHaveReceived('bound')->with('events')->times(4); + $this->assertEquals($jobs[2], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); $this->assertEquals($jobs[1], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); $this->assertEquals($jobs[3], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); @@ -75,10 +85,6 @@ class RedisQueueIntegrationTest extends TestCase */ public function testBlockingPop($driver) { - if (! function_exists('pcntl_fork')) { - $this->markTestSkipped('Skipping since the pcntl extension is not available'); - } - $this->tearDownRedis(); if ($pid = pcntl_fork() > 0) { @@ -116,7 +122,7 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testPopProperlyPopsJobOffOfRedis($driver) { @@ -128,7 +134,7 @@ class RedisQueueIntegrationTest extends TestCase // Pop and check it is popped correctly $before = $this->currentTime(); - /** @var RedisJob $redisJob */ + /** @var \Illuminate\Queue\Jobs\RedisJob $redisJob */ $redisJob = $this->queue->pop(); $after = $this->currentTime(); @@ -151,7 +157,7 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testPopProperlyPopsDelayedJobOffOfRedis($driver) { @@ -178,17 +184,18 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testPopPopsDelayedJobOffOfRedisWhenExpireNull($driver) { - $this->queue = new RedisQueue($this->redis[$driver], 'default', null, null); - $this->queue->setContainer(m::mock(Container::class)); + $this->setQueue($driver, 'default', null, null); // Push an item into queue $job = new RedisQueueIntegrationTestJob(10); $this->queue->later(-10, $job); + $this->container->shouldHaveReceived('bound')->with('events')->once(); + // Pop and check it is popped correctly $before = $this->currentTime(); $this->assertEquals($job, unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); @@ -207,7 +214,7 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testBlockingPopProperlyPopsJobOffOfRedis($driver) { @@ -218,7 +225,7 @@ class RedisQueueIntegrationTest extends TestCase $this->queue->push($job); // Pop and check it is popped correctly - /** @var RedisJob $redisJob */ + /** @var \Illuminate\Queue\Jobs\RedisJob $redisJob */ $redisJob = $this->queue->pop(); $this->assertNotNull($redisJob); @@ -228,10 +235,14 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testBlockingPopProperlyPopsExpiredJobs($driver) { + Str::createUuidsUsing(function () { + return 'uuid'; + }); + $this->setQueue($driver, 'default', null, 60, 5); $jobs = [ @@ -248,21 +259,24 @@ class RedisQueueIntegrationTest extends TestCase $this->assertEquals(0, $this->redis[$driver]->connection()->llen('queues:default:notify')); $this->assertEquals(0, $this->redis[$driver]->connection()->zcard('queues:default:delayed')); $this->assertEquals(2, $this->redis[$driver]->connection()->zcard('queues:default:reserved')); + + Str::createUuidsNormally(); } /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testNotExpireJobsWhenExpireNull($driver) { - $this->queue = new RedisQueue($this->redis[$driver], 'default', null, null); - $this->queue->setContainer(m::mock(Container::class)); + $this->setQueue($driver, 'default', null, null); // Make an expired reserved job $failed = new RedisQueueIntegrationTestJob(-20); $this->queue->push($failed); + $this->container->shouldHaveReceived('bound')->with('events')->once(); + $beforeFailPop = $this->currentTime(); $this->queue->pop(); $afterFailPop = $this->currentTime(); @@ -270,6 +284,7 @@ class RedisQueueIntegrationTest extends TestCase // Push an item into queue $job = new RedisQueueIntegrationTestJob(10); $this->queue->push($job); + $this->container->shouldHaveReceived('bound')->with('events')->times(2); // Pop and check it is popped correctly $before = $this->currentTime(); @@ -298,16 +313,16 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testExpireJobsWhenExpireSet($driver) { - $this->queue = new RedisQueue($this->redis[$driver], 'default', null, 30); - $this->queue->setContainer(m::mock(Container::class)); + $this->setQueue($driver, 'default', null, 30); // Push an item into queue $job = new RedisQueueIntegrationTestJob(10); $this->queue->push($job); + $this->container->shouldHaveReceived('bound')->with('events')->once(); // Pop and check it is popped correctly $before = $this->currentTime(); @@ -327,24 +342,24 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testRelease($driver) { $this->setQueue($driver); - //push a job into queue + // push a job into queue $job = new RedisQueueIntegrationTestJob(30); $this->queue->push($job); - //pop and release the job + // pop and release the job /** @var \Illuminate\Queue\Jobs\RedisJob $redisJob */ $redisJob = $this->queue->pop(); $before = $this->currentTime(); $redisJob->release(1000); $after = $this->currentTime(); - //check the content of delayed queue + // check the content of delayed queue $this->assertEquals(1, $this->redis[$driver]->connection()->zcard('queues:default:delayed')); $results = $this->redis[$driver]->connection()->zrangebyscore('queues:default:delayed', -INF, INF, ['withscores' => true]); @@ -361,14 +376,14 @@ class RedisQueueIntegrationTest extends TestCase $this->assertEquals(1, $decoded->attempts); $this->assertEquals($job, unserialize($decoded->data->command)); - //check if the queue has no ready item yet + // check if the queue has no ready item yet $this->assertNull($this->queue->pop()); } /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testReleaseInThePast($driver) { @@ -376,7 +391,7 @@ class RedisQueueIntegrationTest extends TestCase $job = new RedisQueueIntegrationTestJob(30); $this->queue->push($job); - /** @var RedisJob $redisJob */ + /** @var \Illuminate\Queue\Jobs\RedisJob $redisJob */ $redisJob = $this->queue->pop(); $redisJob->release(-3); @@ -386,7 +401,7 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver */ public function testDelete($driver) { @@ -410,7 +425,27 @@ class RedisQueueIntegrationTest extends TestCase /** * @dataProvider redisDriverProvider * - * @param string $driver + * @param string $driver + */ + public function testClear($driver) + { + $this->setQueue($driver); + + $job1 = new RedisQueueIntegrationTestJob(30); + $job2 = new RedisQueueIntegrationTestJob(40); + + $this->queue->push($job1); + $this->queue->push($job2); + + $this->assertEquals(2, $this->queue->clear(null)); + $this->assertEquals(0, $this->queue->size()); + $this->assertEquals(0, $this->redis[$driver]->connection()->llen('queues:default:notify')); + } + + /** + * @dataProvider redisDriverProvider + * + * @param string $driver */ public function testSize($driver) { @@ -429,16 +464,66 @@ class RedisQueueIntegrationTest extends TestCase } /** - * @param string $driver + * @dataProvider redisDriverProvider + * + * @param string $driver + */ + public function testPushJobQueuedEvent($driver) + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->withArgs(function (JobQueued $jobQueued) { + $this->assertInstanceOf(RedisQueueIntegrationTestJob::class, $jobQueued->job); + $this->assertIsString(RedisQueueIntegrationTestJob::class, $jobQueued->id); + + return true; + })->andReturnNull()->once(); + + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(true)->once(); + $container->shouldReceive('offsetGet')->with('events')->andReturn($events)->once(); + + $queue = new RedisQueue($this->redis[$driver]); + $queue->setContainer($container); + + $queue->push(new RedisQueueIntegrationTestJob(5)); + } + + /** + * @dataProvider redisDriverProvider + * + * @param string $driver + */ + public function testBulkJobQueuedEvent($driver) + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->with(m::type(JobQueued::class))->andReturnNull()->times(3); + + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(true)->times(3); + $container->shouldReceive('offsetGet')->with('events')->andReturn($events)->times(3); + + $queue = new RedisQueue($this->redis[$driver]); + $queue->setContainer($container); + + $queue->bulk([ + new RedisQueueIntegrationTestJob(5), + new RedisQueueIntegrationTestJob(10), + new RedisQueueIntegrationTestJob(15), + ]); + } + + /** + * @param string $driver * @param string $default - * @param string $connection + * @param string|null $connection * @param int $retryAfter * @param int|null $blockFor */ private function setQueue($driver, $default = 'default', $connection = null, $retryAfter = 60, $blockFor = null) { $this->queue = new RedisQueue($this->redis[$driver], $default, $connection, $retryAfter, $blockFor); - $this->queue->setContainer(m::mock(Container::class)); + $this->container = m::spy(Container::class); + $this->queue->setContainer($this->container); } } diff --git a/tests/Redis/ConcurrentLimiterTest.php b/tests/Redis/ConcurrentLimiterTest.php index 78a6792274a5e599e169101ba2777f4d6ec6ac67..22d5d0f385c729b3145d9efe51aea82c38241943 100644 --- a/tests/Redis/ConcurrentLimiterTest.php +++ b/tests/Redis/ConcurrentLimiterTest.php @@ -2,15 +2,13 @@ namespace Illuminate\Tests\Redis; +use Error; use Illuminate\Contracts\Redis\LimiterTimeoutException; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; use Illuminate\Redis\Limiters\ConcurrencyLimiter; use PHPUnit\Framework\TestCase; use Throwable; -/** - * @group redislimiters - */ class ConcurrentLimiterTest extends TestCase { use InteractsWithRedis; @@ -140,6 +138,27 @@ class ConcurrentLimiterTest extends TestCase $this->assertEquals([1], $store); } + public function testItReleasesIfErrorIsThrown() + { + $store = []; + + $lock = new ConcurrencyLimiter($this->redis(), 'key', 1, 5); + + try { + $lock->block(1, function () { + throw new Error; + }); + } catch (Error $e) { + } + + $lock = new ConcurrencyLimiter($this->redis(), 'key', 1, 5); + $lock->block(1, function () use (&$store) { + $store[] = 1; + }); + + $this->assertEquals([1], $store); + } + private function redis() { return $this->redis['phpredis']->connection(); diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php index 3b10d092281f83d83eb962d087c87a8a4243b58a..32539958b0db54fb179b2ac9bfdf99ee914eb3fd 100644 --- a/tests/Redis/DurationLimiterTest.php +++ b/tests/Redis/DurationLimiterTest.php @@ -8,9 +8,6 @@ use Illuminate\Redis\Limiters\DurationLimiter; use PHPUnit\Framework\TestCase; use Throwable; -/** - * @group redislimiters - */ class DurationLimiterTest extends TestCase { use InteractsWithRedis; @@ -91,7 +88,7 @@ class DurationLimiterTest extends TestCase return 'foo'; }); - $this->assertEquals('foo', $result); + $this->assertSame('foo', $result); } private function redis() diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 5326a09dd608b03a8f06d674db969685e2b5c2d9..a89ebd2d4fc4473d739303fbe084e87ef45b5c36 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; use Illuminate\Redis\Connections\Connection; +use Illuminate\Redis\Connections\PhpRedisConnection; use Illuminate\Redis\RedisManager; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -329,6 +330,10 @@ class RedisConnectionTest extends TestCase 'count' => 2, ], ])); + $this->assertEquals(['matt' => 5, 'taylor' => 10], $redis->zrangebyscore('set', 0, 11, [ + 'withscores' => true, + 'limit' => [1, 2], + ])); $redis->flushall(); } @@ -346,6 +351,10 @@ class RedisConnectionTest extends TestCase 'count' => 2, ], ])); + $this->assertEquals(['matt' => 5, 'jeffrey' => 1], $redis->ZREVRANGEBYSCORE('set', 10, 0, [ + 'withscores' => true, + 'limit' => [1, 2], + ])); $redis->flushall(); } @@ -458,10 +467,38 @@ class RedisConnectionTest extends TestCase } } + public function testItFlushes() + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'Till'); + $this->assertSame(1, $redis->exists('name')); + + $redis->flushdb(); + $this->assertSame(0, $redis->exists('name')); + } + } + + public function testItFlushesAsynchronous() + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'Till'); + $this->assertSame(1, $redis->exists('name')); + + $redis->flushdb('ASYNC'); + $this->assertSame(0, $redis->exists('name')); + } + } + public function testItRunsEval() { foreach ($this->connections() as $redis) { - $redis->eval('redis.call("set", KEYS[1], ARGV[1])', 1, 'name', 'mohamed'); + if ($redis instanceof PhpRedisConnection) { + // User must decide what needs to be serialized and compressed. + $redis->eval('redis.call("set", KEYS[1], ARGV[1])', 1, 'name', ...$redis->pack(['mohamed'])); + } else { + $redis->eval('redis.call("set", KEYS[1], ARGV[1])', 1, 'name', 'mohamed'); + } + $this->assertSame('mohamed', $redis->get('name')); $redis->flushall(); @@ -569,7 +606,7 @@ class RedisConnectionTest extends TestCase } foreach ($returnedKeys as $returnedKey) { - $this->assertTrue(in_array($returnedKey, $initialKeys)); + $this->assertContains($returnedKey, $initialKeys); } } while ($iterator > 0); @@ -690,6 +727,33 @@ class RedisConnectionTest extends TestCase } } + public function testItSPopsForKeys() + { + foreach ($this->connections() as $redis) { + $members = ['test:spop:1', 'test:spop:2', 'test:spop:3', 'test:spop:4']; + + foreach ($members as $member) { + $redis->sadd('set', $member); + } + + $result = $redis->spop('set'); + $this->assertIsNotArray($result); + $this->assertContains($result, $members); + + $result = $redis->spop('set', 1); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + + $result = $redis->spop('set', 2); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + + $redis->flushAll(); + } + } + public function testPhpRedisScanOption() { foreach ($this->connections() as $redis) { @@ -742,7 +806,7 @@ class RedisConnectionTest extends TestCase $host = env('REDIS_HOST', '127.0.0.1'); $port = env('REDIS_PORT', 6379); - $prefixedPhpredis = new RedisManager(new Application, 'phpredis', [ + $connections[] = (new RedisManager(new Application, 'phpredis', [ 'cluster' => false, 'default' => [ 'url' => "redis://user@$host:$port", @@ -752,9 +816,9 @@ class RedisConnectionTest extends TestCase 'options' => ['prefix' => 'laravel:'], 'timeout' => 0.5, ], - ]); + ]))->connection(); - $persistentPhpRedis = new RedisManager(new Application, 'phpredis', [ + $connections['persistent'] = (new RedisManager(new Application, 'phpredis', [ 'cluster' => false, 'default' => [ 'host' => $host, @@ -765,9 +829,9 @@ class RedisConnectionTest extends TestCase 'persistent' => true, 'persistent_id' => 'laravel', ], - ]); + ]))->connection(); - $serializerPhpRedis = new RedisManager(new Application, 'phpredis', [ + $connections[] = (new RedisManager(new Application, 'phpredis', [ 'cluster' => false, 'default' => [ 'host' => $host, @@ -776,9 +840,9 @@ class RedisConnectionTest extends TestCase 'options' => ['serializer' => Redis::SERIALIZER_JSON], 'timeout' => 0.5, ], - ]); + ]))->connection(); - $scanRetryPhpRedis = new RedisManager(new Application, 'phpredis', [ + $connections[] = (new RedisManager(new Application, 'phpredis', [ 'cluster' => false, 'default' => [ 'host' => $host, @@ -787,12 +851,145 @@ class RedisConnectionTest extends TestCase 'options' => ['scan' => Redis::SCAN_RETRY], 'timeout' => 0.5, ], - ]); - - $connections[] = $prefixedPhpredis->connection(); - $connections[] = $serializerPhpRedis->connection(); - $connections[] = $scanRetryPhpRedis->connection(); - $connections['persistent'] = $persistentPhpRedis->connection(); + ]))->connection(); + + if (defined('Redis::COMPRESSION_LZF')) { + $connections['compression_lzf'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 9, + 'options' => [ + 'compression' => Redis::COMPRESSION_LZF, + 'name' => 'compression_lzf', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + } + + if (defined('Redis::COMPRESSION_ZSTD')) { + $connections['compression_zstd'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 10, + 'options' => [ + 'compression' => Redis::COMPRESSION_ZSTD, + 'name' => 'compression_zstd', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_zstd_default'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 11, + 'options' => [ + 'compression' => Redis::COMPRESSION_ZSTD, + 'compression_level' => Redis::COMPRESSION_ZSTD_DEFAULT, + 'name' => 'compression_zstd_default', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_zstd_min'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 12, + 'options' => [ + 'compression' => Redis::COMPRESSION_ZSTD, + 'compression_level' => Redis::COMPRESSION_ZSTD_MIN, + 'name' => 'compression_zstd_min', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_zstd_max'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 13, + 'options' => [ + 'compression' => Redis::COMPRESSION_ZSTD, + 'compression_level' => Redis::COMPRESSION_ZSTD_MAX, + 'name' => 'compression_zstd_max', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + } + + if (defined('Redis::COMPRESSION_LZ4')) { + $connections['compression_lz4'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 14, + 'options' => [ + 'compression' => Redis::COMPRESSION_LZ4, + 'name' => 'compression_lz4', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_lz4_default'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 15, + 'options' => [ + 'compression' => Redis::COMPRESSION_LZ4, + 'compression_level' => 0, + 'name' => 'compression_lz4_default', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_lz4_min'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 16, + 'options' => [ + 'compression' => Redis::COMPRESSION_LZ4, + 'compression_level' => 1, + 'name' => 'compression_lz4_min', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + + $connections['compression_lz4_max'] = (new RedisManager(new Application, 'phpredis', [ + 'cluster' => false, + 'default' => [ + 'host' => $host, + 'port' => $port, + 'database' => 17, + 'options' => [ + 'compression' => Redis::COMPRESSION_LZ4, + 'compression_level' => 12, + 'name' => 'compression_lz4_max', + ], + 'timeout' => 0.5, + ], + ]))->connection(); + } return $connections; } diff --git a/tests/Redis/RedisConnectorTest.php b/tests/Redis/RedisConnectorTest.php index 599fa2f2aad3183bb41fd36b04104354a28b1d10..86e330538848897bf7e479ddfa3797dc4939f7fc 100644 --- a/tests/Redis/RedisConnectorTest.php +++ b/tests/Redis/RedisConnectorTest.php @@ -5,7 +5,6 @@ namespace Illuminate\Tests\Redis; use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis; use Illuminate\Redis\RedisManager; -use Mockery as m; use PHPUnit\Framework\TestCase; class RedisConnectorTest extends TestCase @@ -23,8 +22,6 @@ class RedisConnectorTest extends TestCase parent::tearDown(); $this->tearDownRedis(); - - m::close(); } public function testDefaultConfiguration() @@ -34,13 +31,14 @@ class RedisConnectorTest extends TestCase $predisClient = $this->redis['predis']->connection()->client(); $parameters = $predisClient->getConnection()->getParameters(); - $this->assertEquals('tcp', $parameters->scheme); + $this->assertSame('tcp', $parameters->scheme); $this->assertEquals($host, $parameters->host); $this->assertEquals($port, $parameters->port); $phpRedisClient = $this->redis['phpredis']->connection()->client(); $this->assertEquals($host, $phpRedisClient->getHost()); $this->assertEquals($port, $phpRedisClient->getPort()); + $this->assertSame('default', $phpRedisClient->client('GETNAME')); } public function testUrl() @@ -61,7 +59,7 @@ class RedisConnectorTest extends TestCase ]); $predisClient = $predis->connection()->client(); $parameters = $predisClient->getConnection()->getParameters(); - $this->assertEquals('tcp', $parameters->scheme); + $this->assertSame('tcp', $parameters->scheme); $this->assertEquals($host, $parameters->host); $this->assertEquals($port, $parameters->port); @@ -77,7 +75,7 @@ class RedisConnectorTest extends TestCase ], ]); $phpRedisClient = $phpRedis->connection()->client(); - $this->assertEquals("tcp://{$host}", $phpRedisClient->getHost()); + $this->assertSame("tcp://{$host}", $phpRedisClient->getHost()); $this->assertEquals($port, $phpRedisClient->getPort()); } @@ -99,7 +97,7 @@ class RedisConnectorTest extends TestCase ]); $predisClient = $predis->connection()->client(); $parameters = $predisClient->getConnection()->getParameters(); - $this->assertEquals('tls', $parameters->scheme); + $this->assertSame('tls', $parameters->scheme); $this->assertEquals($host, $parameters->host); $this->assertEquals($port, $parameters->port); @@ -115,7 +113,7 @@ class RedisConnectorTest extends TestCase ], ]); $phpRedisClient = $phpRedis->connection()->client(); - $this->assertEquals("tcp://{$host}", $phpRedisClient->getHost()); + $this->assertSame("tcp://{$host}", $phpRedisClient->getHost()); $this->assertEquals($port, $phpRedisClient->getPort()); } @@ -139,7 +137,7 @@ class RedisConnectorTest extends TestCase ]); $predisClient = $predis->connection()->client(); $parameters = $predisClient->getConnection()->getParameters(); - $this->assertEquals('tls', $parameters->scheme); + $this->assertSame('tls', $parameters->scheme); $this->assertEquals($host, $parameters->host); $this->assertEquals($port, $parameters->port); @@ -157,7 +155,56 @@ class RedisConnectorTest extends TestCase ], ]); $phpRedisClient = $phpRedis->connection()->client(); - $this->assertEquals("tcp://{$host}", $phpRedisClient->getHost()); + $this->assertSame("tcp://{$host}", $phpRedisClient->getHost()); $this->assertEquals($port, $phpRedisClient->getPort()); } + + public function testPredisConfigurationWithUsername() + { + $host = env('REDIS_HOST', '127.0.0.1'); + $port = env('REDIS_PORT', 6379); + $username = 'testuser'; + $password = 'testpw'; + + $predis = new RedisManager(new Application, 'predis', [ + 'default' => [ + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'database' => 5, + 'timeout' => 0.5, + ], + ]); + $predisClient = $predis->connection()->client(); + $parameters = $predisClient->getConnection()->getParameters(); + $this->assertEquals($username, $parameters->username); + $this->assertEquals($password, $parameters->password); + } + + public function testPredisConfigurationWithSentinel() + { + $host = env('REDIS_HOST', '127.0.0.1'); + $port = env('REDIS_PORT', 6379); + + $predis = new RedisManager(new Application, 'predis', [ + 'cluster' => false, + 'options' => [ + 'replication' => 'sentinel', + 'service' => 'mymaster', + 'parameters' => [ + 'default' => [ + 'database' => 5, + ], + ], + ], + 'default' => [ + "tcp://{$host}:{$port}", + ], + ]); + + $predisClient = $predis->connection()->client(); + $parameters = $predisClient->getConnection()->getSentinelConnection()->getParameters(); + $this->assertEquals($host, $parameters->host); + } } diff --git a/tests/Redis/RedisManagerExtensionTest.php b/tests/Redis/RedisManagerExtensionTest.php index c6a710181aec1f498ed86c23d971086aa6973716..5cac41877cc2d8295c2e2f5807cbf9aff36a993b 100644 --- a/tests/Redis/RedisManagerExtensionTest.php +++ b/tests/Redis/RedisManagerExtensionTest.php @@ -11,9 +11,7 @@ use PHPUnit\Framework\TestCase; class RedisManagerExtensionTest extends TestCase { /** - * Redis manager instance. - * - * @var RedisManager + * @var \Illuminate\Redis\RedisManager */ protected $redis; @@ -21,7 +19,7 @@ class RedisManagerExtensionTest extends TestCase { parent::setUp(); - $this->redis = new RedisManager(new Application(), 'my_custom_driver', [ + $this->redis = new RedisManager(new Application, 'my_custom_driver', [ 'default' => [ 'host' => 'some-host', 'port' => 'some-port', @@ -41,7 +39,7 @@ class RedisManagerExtensionTest extends TestCase ]); $this->redis->extend('my_custom_driver', function () { - return new FakeRedisConnnector(); + return new FakeRedisConnector; }); } @@ -74,7 +72,7 @@ class RedisManagerExtensionTest extends TestCase 'url3', ], ]; - $redis = new RedisManager(new Application(), 'my_custom_driver', [ + $redis = new RedisManager(new Application, 'my_custom_driver', [ 'clusters' => [ $name => $config, ], @@ -93,13 +91,13 @@ class RedisManagerExtensionTest extends TestCase } } -class FakeRedisConnnector implements Connector +class FakeRedisConnector implements Connector { /** * Create a new clustered Predis connection. * - * @param array $config - * @param array $options + * @param array $config + * @param array $options * @return \Illuminate\Contracts\Redis\Connection */ public function connect(array $config, array $options) @@ -110,9 +108,9 @@ class FakeRedisConnnector implements Connector /** * Create a new clustered Predis connection. * - * @param array $config - * @param array $clusterOptions - * @param array $options + * @param array $config + * @param array $clusterOptions + * @param array $options * @return \Illuminate\Contracts\Redis\Connection */ public function connectToCluster(array $config, array $clusterOptions, array $options) diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d4acfa63cf82c3c0e240eaafe827ea0021bd3aa4 --- /dev/null +++ b/tests/Routing/ImplicitRouteBindingTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Tests\Routing; + +use Illuminate\Container\Container; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Routing\ImplicitRouteBinding; +use Illuminate\Routing\Route; +use PHPUnit\Framework\TestCase; + +class ImplicitRouteBindingTest extends TestCase +{ + public function test_it_can_resolve_the_implicit_route_bindings_for_the_given_route() + { + $this->expectNotToPerformAssertions(); + + $action = ['uses' => function (ImplicitRouteBindingUser $user) { + return $user; + }]; + + $route = new Route('GET', '/test', $action); + $route->parameters = ['user' => new ImplicitRouteBindingUser]; + + $route->prepareForSerialization(); + + $container = Container::getInstance(); + + ImplicitRouteBinding::resolveForRoute($container, $route); + } +} + +class ImplicitRouteBindingUser extends Model +{ + // +} diff --git a/tests/Routing/RouteActionTest.php b/tests/Routing/RouteActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..dcc403f0fb1015bd940079e94f42c28d57a74f46 --- /dev/null +++ b/tests/Routing/RouteActionTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Tests\Routing; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Routing\RouteAction; +use Laravel\SerializableClosure\SerializableClosure; +use Opis\Closure\SerializableClosure as OpisSerializableClosure; +use PHPUnit\Framework\TestCase; + +class RouteActionTest extends TestCase +{ + public function test_it_can_detect_a_serialized_closure() + { + $callable = function (RouteActionUser $user) { + return $user; + }; + + $action = ['uses' => serialize(\PHP_VERSION_ID < 70400 + ? new OpisSerializableClosure($callable) + : new SerializableClosure($callable) + )]; + + $this->assertTrue(RouteAction::containsSerializedClosure($action)); + + $action = ['uses' => 'FooController@index']; + + $this->assertFalse(RouteAction::containsSerializedClosure($action)); + } +} + +class RouteActionUser extends Model +{ + // +} diff --git a/tests/Routing/RouteCollectionTest.php b/tests/Routing/RouteCollectionTest.php index b278034743cf9a94975a3e51591352cb36e403d3..ad2a2926b89ee36ce8b937642f01c20e92ecbefe 100644 --- a/tests/Routing/RouteCollectionTest.php +++ b/tests/Routing/RouteCollectionTest.php @@ -3,9 +3,12 @@ namespace Illuminate\Tests\Routing; use ArrayIterator; +use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection; +use LogicException; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class RouteCollectionTest extends TestCase { @@ -21,11 +24,6 @@ class RouteCollectionTest extends TestCase $this->routeCollection = new RouteCollection; } - public function testRouteCollectionCanBeConstructed() - { - $this->assertInstanceOf(RouteCollection::class, $this->routeCollection); - } - public function testRouteCollectionCanAddRoute() { $this->routeCollection->add(new Route('GET', 'foo', [ @@ -252,4 +250,35 @@ class RouteCollectionTest extends TestCase $this->assertEquals($routeB, $this->routeCollection->getByName('overwrittenRouteA')); $this->assertEquals($routeB, $this->routeCollection->getByAction('OverwrittenView@view')); } + + public function testCannotCacheDuplicateRouteNames() + { + $this->routeCollection->add( + new Route('GET', 'users', ['uses' => 'UsersController@index', 'as' => 'users']) + ); + $this->routeCollection->add( + new Route('GET', 'users/{user}', ['uses' => 'UsersController@show', 'as' => 'users']) + ); + + $this->expectException(LogicException::class); + + $this->routeCollection->compile(); + } + + public function testRouteCollectionDontMatchNonMatchingDoubleSlashes() + { + $this->expectException(NotFoundHttpException::class); + + $this->routeCollection->add(new Route('GET', 'foo', [ + 'uses' => 'FooController@index', + 'as' => 'foo_index', + ])); + + $request = Request::create('', 'GET'); + // We have to set uri in REQUEST_URI otherwise Request uses parse_url() which trim the slashes + $request->server->set( + 'REQUEST_URI', '//foo' + ); + $this->routeCollection->match($request); + } } diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index 78db4bb4c0eeefba560f411266ca58705a9e8fd3..62825149f17dbfda1001f96068f622360e87deb4 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -3,12 +3,15 @@ namespace Illuminate\Tests\Routing; use BadMethodCallException; +use FooController; use Illuminate\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Http\Request; +use Illuminate\Routing\Route; use Illuminate\Routing\Router; use Mockery as m; use PHPUnit\Framework\TestCase; +use Stringable; class RouteRegistrarTest extends TestCase { @@ -60,6 +63,80 @@ class RouteRegistrarTest extends TestCase $this->assertEquals(['seven'], $this->getRoute()->middleware()); } + public function testNullNamespaceIsRespected() + { + $this->router->middleware(['one'])->namespace(null)->get('users', function () { + return 'all-users'; + }); + + $this->assertNull($this->getRoute()->getAction()['namespace']); + } + + public function testMiddlewareAsStringableObject() + { + $one = new class implements Stringable + { + public function __toString() + { + return 'one'; + } + }; + + $this->router->middleware($one)->get('users', function () { + return 'all-users'; + }); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + $this->assertSame(['one'], $this->getRoute()->middleware()); + } + + public function testMiddlewareAsStringableObjectOnRouteInstance() + { + $one = new class implements Stringable + { + public function __toString() + { + return 'one'; + } + }; + + $this->router->get('users', function () { + return 'all-users'; + })->middleware($one); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + $this->assertSame(['one'], $this->getRoute()->middleware()); + } + + public function testMiddlewareAsArrayWithStringables() + { + $one = new class implements Stringable + { + public function __toString() + { + return 'one'; + } + }; + + $this->router->middleware([$one, 'two'])->get('users', function () { + return 'all-users'; + }); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + $this->assertSame(['one', 'two'], $this->getRoute()->middleware()); + } + + public function testWithoutMiddlewareRegistration() + { + $this->router->middleware(['one', 'two'])->get('users', function () { + return 'all-users'; + })->withoutMiddleware('one'); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + + $this->assertEquals(['one'], $this->getRoute()->excludedMiddleware()); + } + public function testCanRegisterGetRouteWithClosureAction() { $this->router->middleware('get-middleware')->get('users', function () { @@ -138,6 +215,25 @@ class RouteRegistrarTest extends TestCase $this->seeMiddleware('controller-middleware'); } + public function testCanRegisterNamespacedGroupRouteWithControllerActionArray() + { + $this->router->group(['namespace' => 'WhatEver'], function () { + $this->router->middleware('controller-middleware') + ->get('users', [RouteRegistrarControllerStub::class, 'index']); + }); + + $this->seeResponse('controller', Request::create('users', 'GET')); + $this->seeMiddleware('controller-middleware'); + + $this->router->group(['namespace' => 'WhatEver'], function () { + $this->router->middleware('controller-middleware') + ->get('users', ['\\'.RouteRegistrarControllerStub::class, 'index']); + }); + + $this->seeResponse('controller', Request::create('users', 'GET')); + $this->seeMiddleware('controller-middleware'); + } + public function testCanRegisterRouteWithArrayAndControllerAction() { $this->router->middleware('controller-middleware')->put('users', [ @@ -160,6 +256,38 @@ class RouteRegistrarTest extends TestCase $this->seeMiddleware('group-middleware'); } + public function testCanRegisterGroupWithoutMiddleware() + { + $this->router->withoutMiddleware('one')->group(function ($router) { + $router->get('users', function () { + return 'all-users'; + })->middleware(['one', 'two']); + }); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + $this->assertEquals(['one'], $this->getRoute()->excludedMiddleware()); + } + + public function testCanRegisterGroupWithStringableMiddleware() + { + $one = new class implements Stringable + { + public function __toString() + { + return 'one'; + } + }; + + $this->router->middleware($one)->group(function ($router) { + $router->get('users', function () { + return 'all-users'; + }); + }); + + $this->seeResponse('all-users', Request::create('users', 'GET')); + $this->seeMiddleware('one'); + } + public function testCanRegisterGroupWithNamespace() { $this->router->namespace('App\Http\Controllers')->group(function ($router) { @@ -220,6 +348,89 @@ class RouteRegistrarTest extends TestCase $this->assertSame('api.users', $this->getRoute()->getName()); } + public function testCanRegisterGroupWithController() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->get('users', 'index'); + }); + + $this->assertSame( + RouteRegistrarControllerStub::class.'@index', + $this->getRoute()->getAction()['uses'] + ); + } + + public function testCanOverrideGroupControllerWithStringSyntax() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->get('users', 'UserController@index'); + }); + + $this->assertSame( + 'UserController@index', + $this->getRoute()->getAction()['uses'] + ); + } + + public function testCanOverrideGroupControllerWithClosureSyntax() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->get('users', function () { + return 'hello world'; + }); + }); + + $this->seeResponse('hello world', Request::create('users', 'GET')); + } + + public function testCanOverrideGroupControllerWithInvokableControllerSyntax() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->get('users', InvokableRouteRegistrarControllerStub::class); + }); + + $this->assertSame( + InvokableRouteRegistrarControllerStub::class.'@__invoke', + $this->getRoute()->getAction()['uses'] + ); + } + + public function testWillUseTheLatestGroupController() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->group(['controller' => FooController::class], function ($router) { + $router->get('users', 'index'); + }); + }); + + $this->assertSame( + FooController::class.'@index', + $this->getRoute()->getAction()['uses'] + ); + } + + public function testCanOverrideGroupControllerWithArraySyntax() + { + $this->router->controller(RouteRegistrarControllerStub::class)->group(function ($router) { + $router->get('users', [FooController::class, 'index']); + }); + + $this->assertSame( + FooController::class.'@index', + $this->getRoute()->getAction()['uses'] + ); + } + + public function testRouteGroupingWithoutPrefix() + { + $this->router->group([], function ($router) { + $router->prefix('bar')->get('baz', ['as' => 'baz', function () { + return 'hello'; + }]); + }); + $this->seeResponse('hello', Request::create('bar/baz', 'GET')); + } + public function testRegisteringNonApprovedAttributesThrows() { $this->expectException(BadMethodCallException::class); @@ -242,9 +453,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterResourcesWithExceptOption() { $this->router->resources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ], ['except' => ['create', 'show']]); $this->assertCount(15, $this->router->getRoutes()); @@ -264,9 +475,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterResourcesWithOnlyOption() { $this->router->resources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ], ['only' => ['create', 'show']]); $this->assertCount(6, $this->router->getRoutes()); @@ -286,9 +497,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterResourcesWithoutOption() { $this->router->resources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ]); $this->assertCount(21, $this->router->getRoutes()); @@ -304,6 +515,24 @@ class RouteRegistrarTest extends TestCase } } + public function testCanRegisterResourceWithMissingOption() + { + $this->router->middleware('resource-middleware') + ->resource('users', RouteRegistrarControllerStub::class) + ->missing(function () { + return 'missing'; + }); + + $this->assertIsCallable($this->router->getRoutes()->getByName('users.show')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.edit')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.update')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.destroy')->getMissing()); + + $this->assertNull($this->router->getRoutes()->getByName('users.index')->getMissing()); + $this->assertNull($this->router->getRoutes()->getByName('users.create')->getMissing()); + $this->assertNull($this->router->getRoutes()->getByName('users.store')->getMissing()); + } + public function testCanAccessRegisteredResourceRoutesAsRouteCollection() { $resource = $this->router->middleware('resource-middleware') @@ -355,6 +584,31 @@ class RouteRegistrarTest extends TestCase $this->assertFalse($this->router->getRoutes()->hasNamedRoute('users.tasks.show')); } + public function testCanSetScopedOptionOnRegisteredResource() + { + $this->router->resource('users.tasks', RouteRegistrarControllerStub::class)->scoped(); + $this->assertSame( + ['user' => null], + $this->router->getRoutes()->getByName('users.tasks.index')->bindingFields() + ); + $this->assertSame( + ['user' => null, 'task' => null], + $this->router->getRoutes()->getByName('users.tasks.show')->bindingFields() + ); + + $this->router->resource('users.tasks', RouteRegistrarControllerStub::class)->scoped([ + 'task' => 'slug', + ]); + $this->assertSame( + ['user' => null], + $this->router->getRoutes()->getByName('users.tasks.index')->bindingFields() + ); + $this->assertSame( + ['user' => null, 'task' => 'slug'], + $this->router->getRoutes()->getByName('users.tasks.show')->bindingFields() + ); + } + public function testCanExcludeMethodsOnRegisteredApiResource() { $this->router->apiResource('users', RouteRegistrarControllerStub::class) @@ -369,9 +623,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterApiResourcesWithExceptOption() { $this->router->apiResources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ], ['except' => ['create', 'show']]); $this->assertCount(12, $this->router->getRoutes()); @@ -391,9 +645,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterApiResourcesWithOnlyOption() { $this->router->apiResources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ], ['only' => ['index', 'show']]); $this->assertCount(6, $this->router->getRoutes()); @@ -413,9 +667,9 @@ class RouteRegistrarTest extends TestCase public function testCanRegisterApiResourcesWithoutOption() { $this->router->apiResources([ - 'resource-one' => RouteRegistrarControllerStubOne::class, - 'resource-two' => RouteRegistrarControllerStubTwo::class, - 'resource-three' => RouteRegistrarControllerStubThree::class, + 'resource-one' => RouteRegistrarControllerStubOne::class, + 'resource-two' => RouteRegistrarControllerStubTwo::class, + 'resource-three' => RouteRegistrarControllerStubThree::class, ]); $this->assertCount(15, $this->router->getRoutes()); @@ -511,6 +765,93 @@ class RouteRegistrarTest extends TestCase $this->seeMiddleware(RouteRegistrarMiddlewareStub::class); } + public function testResourceWithoutMiddlewareRegistration() + { + $this->router->resource('users', RouteRegistrarControllerStub::class) + ->only('index') + ->middleware(['one', 'two']) + ->withoutMiddleware('one'); + + $this->seeResponse('controller', Request::create('users', 'GET')); + + $this->assertEquals(['one'], $this->getRoute()->excludedMiddleware()); + } + + public function testResourceWithMiddlewareAsStringable() + { + $one = new class implements Stringable + { + public function __toString() + { + return 'one'; + } + }; + + $this->router->resource('users', RouteRegistrarControllerStub::class) + ->only('index') + ->middleware([$one, 'two']) + ->withoutMiddleware('one'); + + $this->seeResponse('controller', Request::create('users', 'GET')); + + $this->assertEquals(['one', 'two'], $this->getRoute()->middleware()); + $this->assertEquals(['one'], $this->getRoute()->excludedMiddleware()); + } + + public function testResourceWheres() + { + $wheres = [ + 'user' => '\d+', + 'test' => '[a-z]+', + ]; + + $this->router->resource('users', RouteRegistrarControllerStub::class) + ->where($wheres); + + /** @var \Illuminate\Routing\Route $route */ + foreach ($this->router->getRoutes() as $route) { + $this->assertEquals($wheres, $route->wheres); + } + } + + public function testWhereNumberRegistration() + { + $wheres = ['foo' => '[0-9]+', 'bar' => '[0-9]+']; + + $this->router->get('/{foo}/{bar}')->whereNumber(['foo', 'bar']); + $this->router->get('/api/{bar}/{foo}')->whereNumber(['bar', 'foo']); + + /** @var \Illuminate\Routing\Route $route */ + foreach ($this->router->getRoutes() as $route) { + $this->assertEquals($wheres, $route->wheres); + } + } + + public function testWhereAlphaRegistration() + { + $wheres = ['foo' => '[a-zA-Z]+', 'bar' => '[a-zA-Z]+']; + + $this->router->get('/{foo}/{bar}')->whereAlpha(['foo', 'bar']); + $this->router->get('/api/{bar}/{foo}')->whereAlpha(['bar', 'foo']); + + /** @var \Illuminate\Routing\Route $route */ + foreach ($this->router->getRoutes() as $route) { + $this->assertEquals($wheres, $route->wheres); + } + } + + public function testWhereAlphaNumericRegistration() + { + $wheres = ['1a2b3c' => '[a-zA-Z0-9]+']; + + $this->router->get('/{foo}')->whereAlphaNumeric(['1a2b3c']); + + /** @var \Illuminate\Routing\Route $route */ + foreach ($this->router->getRoutes() as $route) { + $this->assertEquals($wheres, $route->wheres); + } + } + public function testCanSetRouteName() { $this->router->as('users.index')->get('users', function () { @@ -582,6 +923,14 @@ class RouteRegistrarControllerStub } } +class InvokableRouteRegistrarControllerStub +{ + public function __invoke() + { + return 'controller'; + } +} + class RouteRegistrarMiddlewareStub { // diff --git a/tests/Routing/RouteSignatureParametersTest.php b/tests/Routing/RouteSignatureParametersTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d3cfe254767a56169bc631602c7451740ed341be --- /dev/null +++ b/tests/Routing/RouteSignatureParametersTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Illuminate\Tests\Routing; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Routing\RouteSignatureParameters; +use Laravel\SerializableClosure\SerializableClosure; +use Opis\Closure\SerializableClosure as OpisSerializableClosure; +use PHPUnit\Framework\TestCase; +use ReflectionParameter; + +class RouteSignatureParametersTest extends TestCase +{ + public function test_it_can_extract_the_route_action_signature_parameters() + { + $callable = function (SignatureParametersUser $user) { + return $user; + }; + + $action = ['uses' => serialize(\PHP_VERSION_ID < 70400 + ? new OpisSerializableClosure($callable) + : new SerializableClosure($callable) + )]; + + $parameters = RouteSignatureParameters::fromAction($action); + + $this->assertContainsOnlyInstancesOf(ReflectionParameter::class, $parameters); + $this->assertSame('user', $parameters[0]->getName()); + } +} + +class SignatureParametersUser extends Model +{ + // +} diff --git a/tests/Routing/RouteUriTest.php b/tests/Routing/RouteUriTest.php new file mode 100644 index 0000000000000000000000000000000000000000..41732ae8698d06c9a4620a6724a75421a373c16c --- /dev/null +++ b/tests/Routing/RouteUriTest.php @@ -0,0 +1,36 @@ +<?php + +namespace Illuminate\Tests\Routing; + +use Illuminate\Routing\RouteUri; +use PHPUnit\Framework\TestCase; + +class RouteUriTest extends TestCase +{ + public function testRouteUrisAreProperlyParsed() + { + $parsed = RouteUri::parse('/foo'); + $this->assertSame('/foo', $parsed->uri); + $this->assertEquals([], $parsed->bindingFields); + + $parsed = RouteUri::parse('/foo/{bar}'); + $this->assertSame('/foo/{bar}', $parsed->uri); + $this->assertEquals([], $parsed->bindingFields); + + $parsed = RouteUri::parse('/foo/{bar:slug}'); + $this->assertSame('/foo/{bar}', $parsed->uri); + $this->assertEquals(['bar' => 'slug'], $parsed->bindingFields); + + $parsed = RouteUri::parse('/foo/{bar}/baz/{qux:slug}'); + $this->assertSame('/foo/{bar}/baz/{qux}', $parsed->uri); + $this->assertEquals(['qux' => 'slug'], $parsed->bindingFields); + + $parsed = RouteUri::parse('/foo/{bar}/baz/{qux:slug?}'); + $this->assertSame('/foo/{bar}/baz/{qux?}', $parsed->uri); + $this->assertEquals(['qux' => 'slug'], $parsed->bindingFields); + + $parsed = RouteUri::parse('/foo/{bar}/baz/{qux:slug?}/{test:id?}'); + $this->assertSame('/foo/{bar}/baz/{qux?}/{test?}', $parsed->uri); + $this->assertEquals(['qux' => 'slug', 'test' => 'id'], $parsed->bindingFields); + } +} diff --git a/tests/Routing/RoutingRedirectorTest.php b/tests/Routing/RoutingRedirectorTest.php index 06b4daacc4406b4219e82aa62afa66bbffe0c393..ec86d0ab5e947d22907cc631723f6fe9db238608 100644 --- a/tests/Routing/RoutingRedirectorTest.php +++ b/tests/Routing/RoutingRedirectorTest.php @@ -38,6 +38,7 @@ class RoutingRedirectorTest extends TestCase $this->url->shouldReceive('to')->with('login', [], null)->andReturn('http://foo.com/login'); $this->url->shouldReceive('to')->with('http://foo.com/bar', [], null)->andReturn('http://foo.com/bar'); $this->url->shouldReceive('to')->with('/', [], null)->andReturn('http://foo.com/'); + $this->url->shouldReceive('to')->with('http://foo.com/bar?signature=secret', [], null)->andReturn('http://foo.com/bar?signature=secret'); $this->session = m::mock(Store::class); @@ -161,11 +162,26 @@ class RoutingRedirectorTest extends TestCase $this->assertSame('http://foo.com/bar', $response->getTargetUrl()); } + public function testSignedRoute() + { + $this->url->shouldReceive('signedRoute')->with('home', [], null)->andReturn('http://foo.com/bar?signature=secret'); + + $response = $this->redirect->signedRoute('home'); + $this->assertSame('http://foo.com/bar?signature=secret', $response->getTargetUrl()); + } + + public function testTemporarySignedRoute() + { + $this->url->shouldReceive('temporarySignedRoute')->with('home', 10, [])->andReturn('http://foo.com/bar?signature=secret'); + + $response = $this->redirect->temporarySignedRoute('home', 10); + $this->assertSame('http://foo.com/bar?signature=secret', $response->getTargetUrl()); + } + public function testItSetsValidIntendedUrl() { $this->session->shouldReceive('put')->once()->with('url.intended', 'http://foo.com/bar'); - $result = $this->redirect->setIntendedUrl('http://foo.com/bar'); - $this->assertNull($result); + $this->redirect->setIntendedUrl('http://foo.com/bar'); } } diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index 8064df2792e13ef12ad8486142eebfbe70a1ad29..37799448bf28c5f27bcaa45807df53a5a37c2112 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Routing; +use Closure; use DateTime; use Exception; use Illuminate\Auth\Middleware\Authenticate; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Events\Dispatcher; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; @@ -21,8 +23,10 @@ use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\ResourceRegistrar; use Illuminate\Routing\Route; +use Illuminate\Routing\RouteCollection; use Illuminate\Routing\RouteGroup; use Illuminate\Routing\Router; +use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Str; use LogicException; use PHPUnit\Framework\TestCase; @@ -190,6 +194,30 @@ class RoutingRouteTest extends TestCase $this->assertSame('caught', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); } + public function testMiddlewareCanBeSkipped() + { + $router = $this->getRouter(); + $router->aliasMiddleware('web', RoutingTestMiddlewareGroupTwo::class); + + $router->get('foo/bar', ['middleware' => 'web', function () { + return 'hello'; + }])->withoutMiddleware(RoutingTestMiddlewareGroupTwo::class); + + $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); + } + + public function testMiddlewareCanBeSkippedFromResources() + { + $router = $this->getRouter(); + $router->aliasMiddleware('web', RoutingTestMiddlewareGroupTwo::class); + + $router->resource('foo', RouteTestControllerMiddlewareGroupStub::class) + ->middleware('web') + ->withoutMiddleware(RoutingTestMiddlewareGroupTwo::class); + + $this->assertSame('Hello World', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + } + public function testMiddlewareWorksIfControllerThrowsHttpResponseException() { // Before calling controller @@ -372,6 +400,23 @@ class RoutingRouteTest extends TestCase $this->assertNull($route->getAction('unknown_property')); } + public function testResolvingBindingParameters() + { + $router = $this->getRouter(); + + $route = $router->get('foo/{bar:slug}', function () { + return 'foo'; + })->name('foo'); + + $this->assertSame('slug', $route->bindingFieldFor('bar')); + + $route = $router->get('foo/{bar:slug}/{baz}', function () { + return 'foo'; + })->name('foo'); + + $this->assertNull($route->bindingFieldFor('baz')); + } + public function testMacro() { $router = $this->getRouter(); @@ -421,6 +466,29 @@ class RoutingRouteTest extends TestCase unset($_SERVER['__test.route_inject']); } + public function testNullValuesCanBeInjectedIntoRoutes() + { + $container = new Container; + $router = new Router(new Dispatcher, $container); + $container->singleton(Registrar::class, function () use ($router) { + return $router; + }); + + $container->bind(RoutingTestUserModel::class, function () { + }); + + $router->get('foo/{team}/{post}', [ + 'middleware' => SubstituteBindings::class, + 'uses' => function (?RoutingTestUserModel $userFromContainer, RoutingTestTeamModel $team, $postId) { + $this->assertNull($userFromContainer); + $this->assertInstanceOf(RoutingTestTeamModel::class, $team); + $this->assertSame('bar', $team->value); + $this->assertSame('baz', $postId); + }, + ]); + $router->dispatch(Request::create('foo/bar/baz', 'GET'))->getContent(); + } + public function testOptionsResponsesAreGeneratedByDefault() { $router = $this->getRouter(); @@ -771,6 +839,15 @@ class RoutingRouteTest extends TestCase $this->assertFalse($route->matches($request)); } + public function testRoutePrefixParameterParsing() + { + $route = new Route('GET', '/foo', ['prefix' => 'profiles/{user:username}/portfolios', 'uses' => function () { + // + }]); + + $this->assertSame('profiles/{user}/portfolios/foo', $route->uri()); + } + public function testDotDoesNotMatchEverything() { $route = new Route('GET', 'images/{id}.{ext}', function () { @@ -829,12 +906,13 @@ class RoutingRouteTest extends TestCase SubstituteBindings::class, Placeholder2::class, Authenticate::class, + ExampleMiddleware::class, Placeholder3::class, ]; $router = $this->getRouter(); - $router->middlewarePriority = [Authenticate::class, SubstituteBindings::class, Authorize::class]; + $router->middlewarePriority = [ExampleMiddlewareContract::class, Authenticate::class, SubstituteBindings::class, Authorize::class]; $route = $router->get('foo', ['middleware' => $middleware, 'uses' => function ($name) { return $name; @@ -842,6 +920,7 @@ class RoutingRouteTest extends TestCase $this->assertEquals([ Placeholder1::class, + ExampleMiddleware::class, Authenticate::class, SubstituteBindings::class, Placeholder2::class, @@ -1050,14 +1129,30 @@ class RoutingRouteTest extends TestCase $router = $this->getRouter(); $router->group(['prefix' => 'foo', 'as' => 'Foo::'], function () use ($router) { $router->group(['prefix' => 'bar'], function () use ($router) { - $router->get('baz', ['as' => 'baz', function () { + $router->prefix('foz')->get('baz', ['as' => 'baz', function () { return 'hello'; }]); }); }); $routes = $router->getRoutes(); $route = $routes->getByName('Foo::baz'); - $this->assertSame('foo/bar/baz', $route->uri()); + $this->assertSame('foz/foo/bar/baz', $route->uri()); + } + + public function testNestedRouteGroupingPrefixing() + { + /* + * nested with layer skipped + */ + $router = $this->getRouter(); + $router->group(['prefix' => 'foo', 'as' => 'Foo::'], function () use ($router) { + $router->prefix('bar')->get('baz', ['as' => 'baz', function () { + return 'hello'; + }]); + }); + $routes = $router->getRoutes(); + $route = $routes->getByName('Foo::baz'); + $this->assertSame('bar/foo', $route->getAction('prefix')); } public function testRouteMiddlewareMergeWithMiddlewareAttributesAsStrings() @@ -1113,6 +1208,18 @@ class RoutingRouteTest extends TestCase $routes = $routes->getRoutes(); $routes[0]->prefix('prefix'); $this->assertSame('prefix', $routes[0]->uri()); + + /* + * Prefix homepage with empty prefix + */ + $router = $this->getRouter(); + $router->get('/', function () { + return 'hello'; + }); + $routes = $router->getRoutes(); + $routes = $routes->getRoutes(); + $routes[0]->prefix('/'); + $this->assertSame('/', $routes[0]->uri()); } public function testRoutePreservingOriginalParametersState() @@ -1504,6 +1611,7 @@ class RoutingRouteTest extends TestCase public function testImplicitBindings() { $router = $this->getRouter(); + $router->get('foo/{bar}', [ 'middleware' => SubstituteBindings::class, 'uses' => function (RoutingTestUserModel $bar) { @@ -1512,9 +1620,44 @@ class RoutingRouteTest extends TestCase return $bar->value; }, ]); + $this->assertSame('taylor', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); } + public function testParentChildImplicitBindings() + { + $router = $this->getRouter(); + + $router->get('foo/{user}/{post:slug}', [ + 'middleware' => SubstituteBindings::class, + 'uses' => function (RoutingTestUserModel $user, RoutingTestPostModel $post) { + $this->assertInstanceOf(RoutingTestUserModel::class, $user); + $this->assertInstanceOf(RoutingTestPostModel::class, $post); + + return $user->value.'|'.$post->value; + }, + ]); + + $this->assertSame('1|test-slug', $router->dispatch(Request::create('foo/1/test-slug', 'GET'))->getContent()); + } + + public function testParentChildImplicitBindingsProperlyCamelCased() + { + $router = $this->getRouter(); + + $router->get('foo/{user}/{test_team:id}', [ + 'middleware' => SubstituteBindings::class, + 'uses' => function (RoutingTestUserModel $user, RoutingTestTeamModel $testTeam) { + $this->assertInstanceOf(RoutingTestUserModel::class, $user); + $this->assertInstanceOf(RoutingTestTeamModel::class, $testTeam); + + return $user->value.'|'.$testTeam->value; + }, + ]); + + $this->assertSame('1|4', $router->dispatch(Request::create('foo/1/4', 'GET'))->getContent()); + } + public function testImplicitBindingsWithOptionalParameterWithExistingKeyInUri() { $router = $this->getRouter(); @@ -1529,6 +1672,27 @@ class RoutingRouteTest extends TestCase $this->assertSame('taylor', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); } + public function testImplicitBindingsWithMissingModelHandledByMissing() + { + $router = $this->getRouter(); + $router->get('foo/{bar}', [ + 'middleware' => SubstituteBindings::class, + 'uses' => function (RouteModelBindingNullStub $bar = null) { + $this->assertInstanceOf(RouteModelBindingNullStub::class, $bar); + + return $bar->first(); + }, + ])->missing(function () { + return new RedirectResponse('/', 302); + }); + + $request = Request::create('foo/taylor', 'GET'); + + $response = $router->dispatch($request); + $this->assertTrue($response->isRedirect('/')); + $this->assertEquals(302, $response->getStatusCode()); + } + public function testImplicitBindingsWithOptionalParameterWithNoKeyInUri() { $router = $this->getRouter(); @@ -1599,6 +1763,28 @@ class RoutingRouteTest extends TestCase $this->assertNotInstanceOf(JsonResponse::class, $response); } + public function testRouteFlushController() + { + $container = new Container; + $router = $this->getRouter(); + + $router->get('count', ActionCountStub::class); + $request = Request::create('count', 'GET'); + + $response = $router->dispatch($request); + $this->assertSame(1, $response->original['invokedCount']); + $this->assertSame(1, $response->original['middlewareInvokedCount']); + + $response = $router->dispatch($request); + $this->assertSame(2, $response->original['invokedCount']); + $this->assertSame(2, $response->original['middlewareInvokedCount']); + + $request->route()->flushController(); + $response = $router->dispatch($request); + $this->assertSame(1, $response->original['invokedCount']); + $this->assertSame(1, $response->original['middlewareInvokedCount']); + } + public function testJsonResponseIsReturned() { $router = $this->getRouter(); @@ -1622,6 +1808,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('contact_us', function () { throw new Exception('Route should not be reachable.'); }); @@ -1643,6 +1833,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('contact_us', function () { throw new Exception('Route should not be reachable.'); }); @@ -1664,6 +1858,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('contact_us', function () { throw new Exception('Route should not be reachable.'); }); @@ -1677,7 +1875,7 @@ class RoutingRouteTest extends TestCase public function testRouteRedirectExceptionWhenMissingExpectedParameters() { $this->expectException(UrlGenerationException::class); - $this->expectExceptionMessage('Missing required parameters for [Route: laravel_route_redirect_destination] [URI: users/{user}].'); + $this->expectExceptionMessage('Missing required parameter for [Route: laravel_route_redirect_destination] [URI: users/{user}] [Missing parameter: user].'); $container = new Container; $router = new Router(new Dispatcher, $container); @@ -1688,6 +1886,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('users', function () { throw new Exception('Route should not be reachable.'); }); @@ -1707,6 +1909,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('contact_us', function () { throw new Exception('Route should not be reachable.'); }); @@ -1728,6 +1934,10 @@ class RoutingRouteTest extends TestCase $container->singleton(Request::class, function () use ($request) { return $request; }); + $urlGenerator = new UrlGenerator(new RouteCollection, $request); + $container->singleton(UrlGenerator::class, function () use ($urlGenerator) { + return $urlGenerator; + }); $router->get('contact_us', function () { throw new Exception('Route should not be reachable.'); }); @@ -1738,6 +1948,24 @@ class RoutingRouteTest extends TestCase $this->assertEquals(301, $response->getStatusCode()); } + public function testRouteCanMiddlewareCanBeAssigned() + { + $route = new Route(['GET'], '/', []); + $route->middleware(['foo'])->can('create', Route::class); + + $this->assertEquals([ + 'foo', + 'can:create,Illuminate\Routing\Route', + ], $route->middleware()); + + $route = new Route(['GET'], '/', []); + $route->can('create'); + + $this->assertEquals([ + 'can:create', + ], $route->middleware()); + } + protected function getRouter() { $container = new Container; @@ -1994,6 +2222,16 @@ class RoutingTestMiddlewareGroupTwo class RoutingTestUserModel extends Model { + public function posts() + { + return new RoutingTestPostModel; + } + + public function testTeams() + { + return new RoutingTestTeamModel; + } + public function getRouteKeyName() { return 'id'; @@ -2017,6 +2255,26 @@ class RoutingTestUserModel extends Model } } +class RoutingTestPostModel extends Model +{ + public function getRouteKeyName() + { + return 'id'; + } + + public function where($key, $value) + { + $this->value = $value; + + return $this; + } + + public function first() + { + return $this; + } +} + class RoutingTestTeamModel extends Model { public function getRouteKeyName() @@ -2067,3 +2325,42 @@ class ActionStub return 'hello'; } } + +class ActionCountStub extends Controller +{ + protected $middlewareInvokedCount = 0; + + protected $invokedCount = 0; + + public function __construct() + { + $this->middleware(function ($request, $next) { + $this->middlewareInvokedCount++; + + return $next($request); + }); + } + + public function __invoke() + { + $this->invokedCount++; + + return [ + 'invokedCount' => $this->invokedCount, + 'middlewareInvokedCount' => $this->middlewareInvokedCount, + ]; + } +} + +interface ExampleMiddlewareContract +{ + // +} + +class ExampleMiddleware implements ExampleMiddlewareContract +{ + public function handle($request, Closure $next) + { + return $next($request); + } +} diff --git a/tests/Routing/RoutingUrlGeneratorTest.php b/tests/Routing/RoutingUrlGeneratorTest.php index 39362e9c22442b186b13aec7b3638fbf2331aa7c..f41cb4f96c8060cd2901cd66355d9ab2b48f1e72 100755 --- a/tests/Routing/RoutingUrlGeneratorTest.php +++ b/tests/Routing/RoutingUrlGeneratorTest.php @@ -3,6 +3,7 @@ namespace Illuminate\Tests\Routing; use Illuminate\Contracts\Routing\UrlRoutable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Routing\Route; @@ -355,6 +356,23 @@ class RoutingUrlGeneratorTest extends TestCase $this->assertSame('/foo/routable', $url->route('routable', [$model], false)); } + public function testRoutableInterfaceRoutingWithCustomBindingField() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('http://www.foo.com/') + ); + + $route = new Route(['GET'], 'foo/{bar:slug}', ['as' => 'routable']); + $routes->add($route); + + $model = new RoutableInterfaceStub; + $model->key = 'routable'; + + $this->assertSame('/foo/test-slug', $url->route('routable', ['bar' => $model], false)); + $this->assertSame('/foo/test-slug', $url->route('routable', [$model], false)); + } + public function testRoutableInterfaceRoutingWithSingleParameter() { $url = new UrlGenerator( @@ -533,6 +551,61 @@ class RoutingUrlGeneratorTest extends TestCase $this->assertSame('http://www.foo.com:8080/foo?test=123', $url->route('foo', $parameters)); } + public function provideParametersAndExpectedMeaningfulExceptionMessages() + { + return [ + 'Missing parameters "one", "two" and "three"' => [ + [], + 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, two, three].', + ], + 'Missing parameters "two" and "three"' => [ + ['one' => '123'], + 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: two, three].', + ], + 'Missing parameters "one" and "three"' => [ + ['two' => '123'], + 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, three].', + ], + 'Missing parameters "one" and "two"' => [ + ['three' => '123'], + 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, two].', + ], + 'Missing parameter "three"' => [ + ['one' => '123', 'two' => '123'], + 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: three].', + ], + 'Missing parameter "two"' => [ + ['one' => '123', 'three' => '123'], + 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: two].', + ], + 'Missing parameter "one"' => [ + ['two' => '123', 'three' => '123'], + 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: one].', + ], + ]; + } + + /** + * @dataProvider provideParametersAndExpectedMeaningfulExceptionMessages + */ + public function testUrlGenerationThrowsExceptionForMissingParametersWithMeaningfulMessage($parameters, $expectedMeaningfulExceptionMessage) + { + $this->expectException(UrlGenerationException::class); + $this->expectExceptionMessage($expectedMeaningfulExceptionMessage); + + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('http://www.foo.com:8080/') + ); + + $route = new Route(['GET'], 'foo/{one}/{two}/{three}/{four?}', ['as' => 'foo', function () { + // + }]); + $routes->add($route); + + $url->route('foo', $parameters); + } + public function testForceRootUrl() { $url = new UrlGenerator( @@ -618,6 +691,28 @@ class RoutingUrlGeneratorTest extends TestCase $this->assertFalse($url->hasValidSignature($request)); } + public function testSignedUrlImplicitModelBinding() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + $request = Request::create('http://www.foo.com/') + ); + $url->setKeyResolver(function () { + return 'secret'; + }); + + $route = new Route(['GET'], 'foo/{user:uuid}', ['as' => 'foo', function () { + // + }]); + $routes->add($route); + + $user = new RoutingUrlGeneratorTestUser(['uuid' => '0231d4ac-e9e3-4452-a89a-4427cfb23c3e']); + + $request = Request::create($url->signedRoute('foo', $user)); + + $this->assertTrue($url->hasValidSignature($request)); + } + public function testSignedRelativeUrl() { $url = new UrlGenerator( @@ -664,11 +759,33 @@ class RoutingUrlGeneratorTest extends TestCase Request::create($url->signedRoute('foo', ['signature' => 'bar'])); } + + public function testSignedUrlParameterCannotBeNamedExpires() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + $request = Request::create('http://www.foo.com/') + ); + $url->setKeyResolver(function () { + return 'secret'; + }); + + $route = new Route(['GET'], 'foo/{expires}', ['as' => 'foo', function () { + // + }]); + $routes->add($route); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('reserved'); + + Request::create($url->signedRoute('foo', ['expires' => 253402300799])); + } } class RoutableInterfaceStub implements UrlRoutable { public $key; + public $slug = 'test-slug'; public function getRouteKey() { @@ -680,7 +797,12 @@ class RoutableInterfaceStub implements UrlRoutable return 'key'; } - public function resolveRouteBinding($routeKey) + public function resolveRouteBinding($routeKey, $field = null) + { + // + } + + public function resolveChildRouteBinding($childType, $routeKey, $field = null) { // } @@ -693,3 +815,8 @@ class InvokableActionStub return 'hello'; } } + +class RoutingUrlGeneratorTestUser extends Model +{ + protected $fillable = ['uuid']; +} diff --git a/tests/Session/ArraySessionHandlerTest.php b/tests/Session/ArraySessionHandlerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..278441c683d366b03cccc167451213bf2370adc6 --- /dev/null +++ b/tests/Session/ArraySessionHandlerTest.php @@ -0,0 +1,114 @@ +<?php + +namespace Illuminate\Tests\Session; + +use Illuminate\Session\ArraySessionHandler; +use Illuminate\Support\Carbon; +use PHPUnit\Framework\TestCase; +use SessionHandlerInterface; + +class ArraySessionHandlerTest extends TestCase +{ + public function test_it_implements_the_session_handler_interface() + { + $this->assertInstanceOf(SessionHandlerInterface::class, new ArraySessionHandler(10)); + } + + public function test_it_initializes_the_session() + { + $handler = new ArraySessionHandler(10); + + $this->assertTrue($handler->open('', '')); + } + + public function test_it_closes_the_session() + { + $handler = new ArraySessionHandler(10); + + $this->assertTrue($handler->close()); + } + + public function test_it_reads_data_from_the_session() + { + $handler = new ArraySessionHandler(10); + + $handler->write('foo', 'bar'); + + $this->assertSame('bar', $handler->read('foo')); + } + + public function test_it_reads_data_from_an_almost_expired_session() + { + $handler = new ArraySessionHandler(10); + + $handler->write('foo', 'bar'); + + Carbon::setTestNow(Carbon::now()->addMinutes(10)); + $this->assertSame('bar', $handler->read('foo')); + Carbon::setTestNow(); + } + + public function test_it_reads_data_from_an_expired_session() + { + $handler = new ArraySessionHandler(10); + + $handler->write('foo', 'bar'); + + Carbon::setTestNow(Carbon::now()->addMinutes(10)->addSecond()); + $this->assertSame('', $handler->read('foo')); + Carbon::setTestNow(); + } + + public function test_it_reads_data_from_a_non_existing_session() + { + $handler = new ArraySessionHandler(10); + + $this->assertSame('', $handler->read('foo')); + } + + public function test_it_writes_session_data() + { + $handler = new ArraySessionHandler(10); + + $this->assertTrue($handler->write('foo', 'bar')); + $this->assertSame('bar', $handler->read('foo')); + + $this->assertTrue($handler->write('foo', 'baz')); + $this->assertSame('baz', $handler->read('foo')); + } + + public function test_it_destroys_a_session() + { + $handler = new ArraySessionHandler(10); + + $this->assertTrue($handler->destroy('foo')); + + $handler->write('foo', 'bar'); + + $this->assertTrue($handler->destroy('foo')); + $this->assertSame('', $handler->read('foo')); + } + + public function test_it_cleans_up_old_sessions() + { + $handler = new ArraySessionHandler(10); + + $this->assertTrue($handler->gc(300)); + + $handler->write('foo', 'bar'); + $this->assertTrue($handler->gc(300)); + $this->assertSame('bar', $handler->read('foo')); + + Carbon::setTestNow(Carbon::now()->addSecond()); + + $handler->write('baz', 'qux'); + + Carbon::setTestNow(Carbon::now()->addMinutes(5)); + + $this->assertTrue($handler->gc(300)); + $this->assertSame('', $handler->read('foo')); + $this->assertSame('qux', $handler->read('baz')); + + Carbon::setTestNow(); + } +} diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index 1a825a8e7f8792781a49bc14b4cc02df07d24053..188bdce4d89f923b9f9be6d52a571ace9afa6a66 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -433,7 +433,7 @@ class SessionStoreTest extends TestCase $session = $this->getSession(); $this->assertEquals($session->getName(), $this->getSessionName()); $session->setName('foo'); - $this->assertEquals($session->getName(), 'foo'); + $this->assertSame('foo', $session->getName()); } public function testKeyExists() @@ -452,6 +452,22 @@ class SessionStoreTest extends TestCase $this->assertFalse($session->exists(['hulk.two'])); } + public function testKeyMissing() + { + $session = $this->getSession(); + $session->put('foo', 'bar'); + $this->assertFalse($session->missing('foo')); + $session->put('baz', null); + $session->put('hulk', ['one' => true]); + $this->assertFalse($session->has('baz')); + $this->assertFalse($session->missing('baz')); + $this->assertTrue($session->missing('bogus')); + $this->assertFalse($session->missing(['foo', 'baz'])); + $this->assertTrue($session->missing(['foo', 'baz', 'bogus'])); + $this->assertFalse($session->missing(['hulk.one'])); + $this->assertTrue($session->missing(['hulk.two'])); + } + public function testRememberMethodCallsPutAndReturnsDefault() { $session = $this->getSession(); diff --git a/tests/Session/SessionTableCommandTest.php b/tests/Session/SessionTableCommandTest.php index 4ed794fbfd92857a61367f684465988cf899cc55..8bb1081c933dcc4437521054a84c3d3b135e7217 100644 --- a/tests/Session/SessionTableCommandTest.php +++ b/tests/Session/SessionTableCommandTest.php @@ -50,6 +50,6 @@ class SessionTableCommandTestStub extends SessionTableCommand { public function call($command, array $arguments = []) { - // + return 0; } } diff --git a/tests/Support/Concerns/CountsEnumerations.php b/tests/Support/Concerns/CountsEnumerations.php new file mode 100644 index 0000000000000000000000000000000000000000..a5c04bfa2db09eb38ba5bc26fff8c3e5416f0a97 --- /dev/null +++ b/tests/Support/Concerns/CountsEnumerations.php @@ -0,0 +1,97 @@ +<?php + +namespace Illuminate\Tests\Support\Concerns; + +use Illuminate\Support\Collection; +use Illuminate\Support\LazyCollection; + +trait CountsEnumerations +{ + protected function makeGeneratorFunctionWithRecorder($numbers = 10) + { + $recorder = new Collection(); + + $generatorFunction = function () use ($numbers, $recorder) { + for ($i = 1; $i <= $numbers; $i++) { + $recorder->push($i); + + yield $i; + } + }; + + return [$generatorFunction, $recorder]; + } + + protected function assertDoesNotEnumerate(callable $executor) + { + $this->assertEnumerates(0, $executor); + } + + protected function assertDoesNotEnumerateCollection( + LazyCollection $collection, + callable $executor + ) { + $this->assertEnumeratesCollection($collection, 0, $executor); + } + + protected function assertEnumerates($count, callable $executor) + { + $this->assertEnumeratesCollection( + LazyCollection::times(100), + $count, + $executor + ); + } + + protected function assertEnumeratesCollection( + LazyCollection $collection, + $count, + callable $executor + ) { + $enumerated = 0; + + $data = $this->countEnumerations($collection, $enumerated); + + $executor($data); + + $this->assertEnumerations($count, $enumerated); + } + + protected function assertEnumeratesOnce(callable $executor) + { + $this->assertEnumeratesCollectionOnce(LazyCollection::times(10), $executor); + } + + protected function assertEnumeratesCollectionOnce( + LazyCollection $collection, + callable $executor + ) { + $enumerated = 0; + $count = $collection->count(); + $collection = $this->countEnumerations($collection, $enumerated); + + $executor($collection); + + $this->assertEquals( + $count, + $enumerated, + $count > $enumerated ? 'Failed to enumerate in full.' : 'Enumerated more than once.' + ); + } + + protected function assertEnumerations($expected, $actual) + { + $this->assertEquals( + $expected, + $actual, + "Failed asserting that {$actual} items that were enumerated matches expected {$expected}." + ); + } + + protected function countEnumerations(LazyCollection $collection, &$count) + { + return $collection->tapEach(function () use (&$count) { + $count++; + }); + } +} diff --git a/tests/Support/ConfigurationUrlParserTest.php b/tests/Support/ConfigurationUrlParserTest.php index e4cfa0bb0a735613a7fbe99e3892e484481cc9e9..bcbc343d1cf9dd97b967755dad86d1070269baae 100644 --- a/tests/Support/ConfigurationUrlParserTest.php +++ b/tests/Support/ConfigurationUrlParserTest.php @@ -354,8 +354,8 @@ class ConfigurationUrlParserTest extends TestCase // Coming directly from Heroku documentation 'url' => 'redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, + 'password' => null, + 'port' => 6379, 'database' => 0, ], [ @@ -367,7 +367,7 @@ class ConfigurationUrlParserTest extends TestCase 'password' => 'asdfqwer1234asdf', ], ], - 'Redis example where URL ends with "/" and database is not present' => [ + 'Redis example where URL ends with "/" and database is not present' => [ [ 'url' => 'redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111/', 'host' => '127.0.0.1', @@ -388,8 +388,8 @@ class ConfigurationUrlParserTest extends TestCase [ 'url' => 'tls://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, + 'password' => null, + 'port' => 6379, 'database' => 0, ], [ @@ -405,8 +405,8 @@ class ConfigurationUrlParserTest extends TestCase [ 'url' => 'rediss://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, + 'password' => null, + 'port' => 6379, 'database' => 0, ], [ diff --git a/tests/Support/DateFacadeTest.php b/tests/Support/DateFacadeTest.php index a7c7e5f913e7d20c71f67069b73c00ae4199ab43..8f80e23480716dbab506b9a6fffe01655c7280c7 100644 --- a/tests/Support/DateFacadeTest.php +++ b/tests/Support/DateFacadeTest.php @@ -4,11 +4,11 @@ namespace Illuminate\Tests\Support; use Carbon\CarbonImmutable; use Carbon\Factory; -use CustomDateClass; use DateTime; use Illuminate\Support\Carbon; use Illuminate\Support\DateFactory; use Illuminate\Support\Facades\Date; +use Illuminate\Tests\Support\Fixtures\CustomDateClass; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -76,7 +76,6 @@ class DateFacadeTest extends TestCase $this->assertSame('fr', Date::now()->locale); DateFactory::use(Carbon::class); $this->assertSame('en', Date::now()->locale); - include_once __DIR__.'/fixtures/CustomDateClass.php'; DateFactory::use(CustomDateClass::class); $this->assertInstanceOf(CustomDateClass::class, Date::now()); $this->assertInstanceOf(Carbon::class, Date::now()->getOriginal()); diff --git a/tests/Support/Enums.php b/tests/Support/Enums.php new file mode 100644 index 0000000000000000000000000000000000000000..72dad01de69fea95d2f289909b22290dbadc6473 --- /dev/null +++ b/tests/Support/Enums.php @@ -0,0 +1,13 @@ +<?php + +namespace Illuminate\Tests\Support; + +enum TestEnum +{ + case A; +} + +enum TestBackedEnum: int +{ + case A = 1; +} diff --git a/tests/Support/fixtures/CustomDateClass.php b/tests/Support/Fixtures/CustomDateClass.php similarity index 87% rename from tests/Support/fixtures/CustomDateClass.php rename to tests/Support/Fixtures/CustomDateClass.php index 7b806c6052ce3d6aefe2819352c6189c3cbb31ea..d5ec2e69fd326139ee6fc0568c29ec3291919eb1 100644 --- a/tests/Support/fixtures/CustomDateClass.php +++ b/tests/Support/Fixtures/CustomDateClass.php @@ -1,5 +1,7 @@ <?php +namespace Illuminate\Tests\Support\Fixtures; + class CustomDateClass { protected $original; diff --git a/tests/Support/Fixtures/UnionTypesClosure.php b/tests/Support/Fixtures/UnionTypesClosure.php new file mode 100644 index 0000000000000000000000000000000000000000..48cf8f91e73da1dcc65afff0f51c19084ed65b12 --- /dev/null +++ b/tests/Support/Fixtures/UnionTypesClosure.php @@ -0,0 +1,8 @@ +<?php + +use Illuminate\Tests\Support\AnotherExampleParameter; +use Illuminate\Tests\Support\ExampleParameter; + +return function (ExampleParameter|AnotherExampleParameter $a, $b) { + // +}; diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index 03791b2975f5154f6c3ef1669d356a3a276922bf..5094991907d6fdf431d58bdcab80e5419ecfdac7 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -110,7 +110,31 @@ class SupportArrTest extends TestCase $this->assertEquals(['foo.bar' => []], $array); $array = Arr::dot(['name' => 'taylor', 'languages' => ['php' => true]]); - $this->assertEquals($array, ['name' => 'taylor', 'languages.php' => true]); + $this->assertEquals(['name' => 'taylor', 'languages.php' => true], $array); + } + + public function testUndot() + { + $array = Arr::undot([ + 'user.name' => 'Taylor', + 'user.age' => 25, + 'user.languages.0' => 'PHP', + 'user.languages.1' => 'C#', + ]); + $this->assertEquals(['user' => ['name' => 'Taylor', 'age' => 25, 'languages' => ['PHP', 'C#']]], $array); + + $array = Arr::undot([ + 'pagination.previous' => '<<', + 'pagination.next' => '>>', + ]); + $this->assertEquals(['pagination' => ['previous' => '<<', 'next' => '>>']], $array); + + $array = Arr::undot([ + 'foo', + 'foo.bar' => 'baz', + 'foo.baz' => ['a' => 'b'], + ]); + $this->assertEquals(['foo', 'foo' => ['bar' => 'baz', 'baz' => ['a' => 'b']]], $array); } public function testExcept() @@ -139,16 +163,47 @@ class SupportArrTest extends TestCase $this->assertFalse(Arr::exists(new Collection(['a' => null]), 'b')); } + public function testWhereNotNull() + { + $array = array_values(Arr::whereNotNull([null, 0, false, '', null, []])); + $this->assertEquals([0, false, '', []], $array); + } + public function testFirst() { $array = [100, 200, 300]; + // Callback is null and array is empty + $this->assertNull(Arr::first([], null)); + $this->assertSame('foo', Arr::first([], null, 'foo')); + $this->assertSame('bar', Arr::first([], null, function () { + return 'bar'; + })); + + // Callback is null and array is not empty + $this->assertEquals(100, Arr::first($array)); + + // Callback is not null and array is not empty $value = Arr::first($array, function ($value) { return $value >= 150; }); - $this->assertEquals(200, $value); - $this->assertEquals(100, Arr::first($array)); + + // Callback is not null, array is not empty but no satisfied item + $value2 = Arr::first($array, function ($value) { + return $value > 300; + }); + $value3 = Arr::first($array, function ($value) { + return $value > 300; + }, 'bar'); + $value4 = Arr::first($array, function ($value) { + return $value > 300; + }, function () { + return 'baz'; + }); + $this->assertNull($value2); + $this->assertSame('bar', $value3); + $this->assertSame('baz', $value4); } public function testLast() @@ -399,6 +454,22 @@ class SupportArrTest extends TestCase $this->assertFalse(Arr::isAssoc(['a', 'b'])); } + public function testIsList() + { + $this->assertTrue(Arr::isList([])); + $this->assertTrue(Arr::isList([1, 2, 3])); + $this->assertTrue(Arr::isList(['foo', 2, 3])); + $this->assertTrue(Arr::isList(['foo', 'bar'])); + $this->assertTrue(Arr::isList([0 => 'foo', 'bar'])); + $this->assertTrue(Arr::isList([0 => 'foo', 1 => 'bar'])); + + $this->assertFalse(Arr::isList([1 => 'foo', 'bar'])); + $this->assertFalse(Arr::isList([1 => 'foo', 0 => 'bar'])); + $this->assertFalse(Arr::isList([0 => 'foo', 'bar' => 'baz'])); + $this->assertFalse(Arr::isList([0 => 'foo', 2 => 'bar'])); + $this->assertFalse(Arr::isList(['foo' => 'bar', 'baz' => 'qux'])); + } + public function testOnly() { $array = ['name' => 'Desk', 'price' => 100, 'orders' => 10]; @@ -539,6 +610,9 @@ class SupportArrTest extends TestCase $array = Arr::prepend(['one' => 1, 'two' => 2], 0, 'zero'); $this->assertEquals(['zero' => 0, 'one' => 1, 'two' => 2], $array); + + $array = Arr::prepend(['one' => 1, 'two' => 2], 0, null); + $this->assertEquals([null => 0, 'one' => 1, 'two' => 2], $array); } public function testPull() @@ -546,19 +620,25 @@ class SupportArrTest extends TestCase $array = ['name' => 'Desk', 'price' => 100]; $name = Arr::pull($array, 'name'); $this->assertSame('Desk', $name); - $this->assertEquals(['price' => 100], $array); + $this->assertSame(['price' => 100], $array); // Only works on first level keys $array = ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']; $name = Arr::pull($array, 'joe@example.com'); $this->assertSame('Joe', $name); - $this->assertEquals(['jane@localhost' => 'Jane'], $array); + $this->assertSame(['jane@localhost' => 'Jane'], $array); // Does not work for nested keys $array = ['emails' => ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']]; $name = Arr::pull($array, 'emails.joe@example.com'); $this->assertNull($name); - $this->assertEquals(['emails' => ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']], $array); + $this->assertSame(['emails' => ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']], $array); + + // Works with int keys + $array = ['First', 'Second']; + $first = Arr::pull($array, 0); + $this->assertSame('First', $first); + $this->assertSame([1 => 'Second'], $array); } public function testQuery() @@ -605,6 +685,12 @@ class SupportArrTest extends TestCase $this->assertCount(2, $random); $this->assertContains($random[0], ['foo', 'bar', 'baz']); $this->assertContains($random[1], ['foo', 'bar', 'baz']); + + // preserve keys + $random = Arr::random(['one' => 'foo', 'two' => 'bar', 'three' => 'baz'], 2, true); + $this->assertIsArray($random); + $this->assertCount(2, $random); + $this->assertCount(2, array_intersect_assoc(['one' => 'foo', 'two' => 'bar', 'three' => 'baz'], $random)); } public function testRandomOnEmptyArray() @@ -648,6 +734,38 @@ class SupportArrTest extends TestCase $array = ['products' => ['desk' => ['price' => 100]]]; Arr::set($array, 'products.desk.price', 200); $this->assertEquals(['products' => ['desk' => ['price' => 200]]], $array); + + // No key is given + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, null, ['price' => 300]); + $this->assertSame(['price' => 300], $array); + + // The key doesn't exist at the depth + $array = ['products' => 'desk']; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products' => ['desk' => ['price' => 200]]], $array); + + // No corresponding key exists + $array = ['products']; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products', 'products' => ['desk' => ['price' => 200]]], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, 'table', 500); + $this->assertSame(['products' => ['desk' => ['price' => 100]], 'table' => 500], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, 'table.price', 350); + $this->assertSame(['products' => ['desk' => ['price' => 100]], 'table' => ['price' => 350]], $array); + + $array = []; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products' => ['desk' => ['price' => 200]]], $array); + + // Override + $array = ['products' => 'table']; + Arr::set($array, 'products.desk.price', 300); + $this->assertSame(['products' => ['desk' => ['price' => 300]]], $array); } public function testShuffleWithSeed() @@ -742,6 +860,25 @@ class SupportArrTest extends TestCase $this->assertEquals($expect, Arr::sortRecursive($array)); } + public function testToCssClasses() + { + $classes = Arr::toCssClasses([ + 'font-bold', + 'mt-4', + ]); + + $this->assertEquals('font-bold mt-4', $classes); + + $classes = Arr::toCssClasses([ + 'font-bold', + 'mt-4', + 'ml-2' => true, + 'mr-2' => false, + ]); + + $this->assertEquals('font-bold mt-4 ml-2', $classes); + } + public function testWhere() { $array = [100, '200', 300, '400', 500]; @@ -837,4 +974,57 @@ class SupportArrTest extends TestCase $this->assertEquals([$obj], Arr::wrap($obj)); $this->assertSame($obj, Arr::wrap($obj)[0]); } + + public function testSortByMany() + { + $unsorted = [ + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ]; + + // sort using keys + $sorted = array_values(Arr::sort($unsorted, [ + 'name', + 'age', + 'meta.key', + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ], $sorted); + + // sort with order + $sortedWithOrder = array_values(Arr::sort($unsorted, [ + 'name', + ['age', false], + ['meta.key', true], + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ], $sortedWithOrder); + + // sort using callable + $sortedWithCallable = array_values(Arr::sort($unsorted, [ + function ($a, $b) { + return $a['name'] <=> $b['name']; + }, + function ($a, $b) { + return $b['age'] <=> $a['age']; + }, + ['meta.key', true], + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ], $sortedWithCallable); + } } diff --git a/tests/Support/SupportCapsuleManagerTraitTest.php b/tests/Support/SupportCapsuleManagerTraitTest.php index 6c51ea1109c0e99f82632423cb64d6287a21f10f..1b503ea591c0c20f84c3a6a18b7cade216210f7f 100644 --- a/tests/Support/SupportCapsuleManagerTraitTest.php +++ b/tests/Support/SupportCapsuleManagerTraitTest.php @@ -23,7 +23,7 @@ class SupportCapsuleManagerTraitTest extends TestCase $this->container = null; $app = new Container; - $this->assertNull($this->setupContainer($app)); + $this->setupContainer($app); $this->assertEquals($app, $this->getContainer()); $this->assertInstanceOf(Fluent::class, $app['config']); } @@ -34,7 +34,7 @@ class SupportCapsuleManagerTraitTest extends TestCase $app = new Container; $app['config'] = m::mock(Repository::class); - $this->assertNull($this->setupContainer($app)); + $this->setupContainer($app); $this->assertEquals($app, $this->getContainer()); $this->assertInstanceOf(Repository::class, $app['config']); } diff --git a/tests/Support/SupportCarbonTest.php b/tests/Support/SupportCarbonTest.php index ea4a53bfff91468a545b95913078455d724db351..cdd865b8b470ce600ca0e75d4853af92f330b99e 100644 --- a/tests/Support/SupportCarbonTest.php +++ b/tests/Support/SupportCarbonTest.php @@ -4,6 +4,7 @@ namespace Illuminate\Tests\Support; use BadMethodCallException; use Carbon\Carbon as BaseCarbon; +use Carbon\CarbonImmutable as BaseCarbonImmutable; use DateTime; use DateTimeInterface; use Illuminate\Support\Carbon; @@ -108,4 +109,13 @@ class SupportCarbonTest extends TestCase $this->assertInstanceOf(Carbon::class, $deserialized); } + + public function testSetTestNowWillPersistBetweenImmutableAndMutableInstance() + { + Carbon::setTestNow(new Carbon('2017-06-27 13:14:15.000000')); + + $this->assertSame('2017-06-27 13:14:15', Carbon::now()->toDateTimeString()); + $this->assertSame('2017-06-27 13:14:15', BaseCarbon::now()->toDateTimeString()); + $this->assertSame('2017-06-27 13:14:15', BaseCarbonImmutable::now()->toDateTimeString()); + } } diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index bdb4048a4f49750a6f8a4fe197897ed9e2e8faa7..b699b8df04893e18a1ad2eb48ed19039d0dbadc0 100755 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -11,13 +11,22 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; +use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\LazyCollection; +use Illuminate\Support\MultipleItemsFoundException; +use Illuminate\Support\Str; use InvalidArgumentException; use JsonSerializable; use Mockery as m; use PHPUnit\Framework\TestCase; use ReflectionClass; use stdClass; +use Symfony\Component\VarDumper\VarDumper; +use UnexpectedValueException; + +if (PHP_VERSION_ID >= 80100) { + include_once 'Enums.php'; +} class SupportCollectionTest extends TestCase { @@ -64,6 +73,199 @@ class SupportCollectionTest extends TestCase $this->assertSame('default', $result); } + /** + * @dataProvider collectionClassProvider + */ + public function testSoleReturnsFirstItemInCollectionIfOnlyOneExists($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->sole()); + $this->assertSame(['name' => 'foo'], $collection->sole('name', '=', 'foo')); + $this->assertSame(['name' => 'foo'], $collection->sole('name', 'foo')); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSoleThrowsExceptionIfNoItemsExist($collection) + { + $this->expectException(ItemNotFoundException::class); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'INVALID')->sole(); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSoleThrowsExceptionIfMoreThanOneItemExists($collection) + { + $this->expectException(MultipleItemsFoundException::class); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'foo')->sole(); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSoleReturnsFirstItemInCollectionIfOnlyOneExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'baz']); + $result = $data->sole(function ($value) { + return $value === 'bar'; + }); + $this->assertSame('bar', $result); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSoleThrowsExceptionIfNoItemsExistWithCallback($collection) + { + $this->expectException(ItemNotFoundException::class); + + $data = new $collection(['foo', 'bar', 'baz']); + + $data->sole(function ($value) { + return $value === 'invalid'; + }); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSoleThrowsExceptionIfMoreThanOneItemExistsWithCallback($collection) + { + $this->expectException(MultipleItemsFoundException::class); + + $data = new $collection(['foo', 'bar', 'bar']); + + $data->sole(function ($value) { + return $value === 'bar'; + }); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailReturnsFirstItemInCollection($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->firstOrFail()); + $this->assertSame(['name' => 'foo'], $collection->firstOrFail('name', '=', 'foo')); + $this->assertSame(['name' => 'foo'], $collection->firstOrFail('name', 'foo')); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailThrowsExceptionIfNoItemsExist($collection) + { + $this->expectException(ItemNotFoundException::class); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'INVALID')->firstOrFail(); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailDoesntThrowExceptionIfMoreThanOneItemExists($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->firstOrFail()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailReturnsFirstItemInCollectionIfOnlyOneExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'baz']); + $result = $data->firstOrFail(function ($value) { + return $value === 'bar'; + }); + $this->assertSame('bar', $result); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailThrowsExceptionIfNoItemsExistWithCallback($collection) + { + $this->expectException(ItemNotFoundException::class); + + $data = new $collection(['foo', 'bar', 'baz']); + + $data->firstOrFail(function ($value) { + return $value === 'invalid'; + }); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailDoesntThrowExceptionIfMoreThanOneItemExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'bar']); + + $this->assertSame( + 'bar', + $data->firstOrFail(function ($value) { + return $value === 'bar'; + }) + ); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testFirstOrFailStopsIteratingAtFirstMatch($collection) + { + $data = new $collection([ + function () { + return false; + }, + function () { + return true; + }, + function () { + throw new Exception(); + }, + ]); + + $this->assertNotNull($data->firstOrFail(function ($callback) { + return $callback(); + })); + } + /** * @dataProvider collectionClassProvider */ @@ -76,8 +278,8 @@ class SupportCollectionTest extends TestCase $this->assertSame('book', $data->firstWhere('material', 'paper')['type']); $this->assertSame('gasket', $data->firstWhere('material', 'rubber')['type']); - $this->assertNull($data->firstWhere('material', 'nonexistant')); - $this->assertNull($data->firstWhere('nonexistant', 'key')); + $this->assertNull($data->firstWhere('material', 'nonexistent')); + $this->assertNull($data->firstWhere('nonexistent', 'key')); } /** @@ -135,6 +337,16 @@ class SupportCollectionTest extends TestCase $this->assertSame('foo', $c->first()); } + public function testPopReturnsAndRemovesLastXItemsInCollection() + { + $c = new Collection(['foo', 'bar', 'baz']); + + $this->assertEquals(new Collection(['baz', 'bar']), $c->pop(2)); + $this->assertSame('foo', $c->first()); + + $this->assertEquals(new Collection(['baz', 'bar', 'foo']), (new Collection(['foo', 'bar', 'baz']))->pop(6)); + } + public function testShiftReturnsAndRemovesFirstItemInCollection() { $data = new Collection(['Taylor', 'Otwell']); @@ -142,7 +354,76 @@ class SupportCollectionTest extends TestCase $this->assertSame('Taylor', $data->shift()); $this->assertSame('Otwell', $data->first()); $this->assertSame('Otwell', $data->shift()); - $this->assertEquals(null, $data->first()); + $this->assertNull($data->first()); + } + + public function testShiftReturnsAndRemovesFirstXItemsInCollection() + { + $data = new Collection(['foo', 'bar', 'baz']); + + $this->assertEquals(new Collection(['foo', 'bar']), $data->shift(2)); + $this->assertSame('baz', $data->first()); + + $this->assertEquals(new Collection(['foo', 'bar', 'baz']), (new Collection(['foo', 'bar', 'baz']))->shift(6)); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSliding($collection) + { + // Default parameters: $size = 2, $step = 1 + $this->assertSame([], $collection::times(0)->sliding()->toArray()); + $this->assertSame([], $collection::times(1)->sliding()->toArray()); + $this->assertSame([[1, 2]], $collection::times(2)->sliding()->toArray()); + $this->assertSame( + [[1, 2], [2, 3]], + $collection::times(3)->sliding()->map->values()->toArray() + ); + + // Custom step: $size = 2, $step = 3 + $this->assertSame([], $collection::times(1)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(2)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(3)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(4)->sliding(2, 3)->toArray()); + $this->assertSame( + [[1, 2], [4, 5]], + $collection::times(5)->sliding(2, 3)->map->values()->toArray() + ); + + // Custom size: $size = 3, $step = 1 + $this->assertSame([], $collection::times(2)->sliding(3)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(3)->sliding(3)->toArray()); + $this->assertSame( + [[1, 2, 3], [2, 3, 4]], + $collection::times(4)->sliding(3)->map->values()->toArray() + ); + $this->assertSame( + [[1, 2, 3], [2, 3, 4]], + $collection::times(4)->sliding(3)->map->values()->toArray() + ); + + // Custom size and custom step: $size = 3, $step = 2 + $this->assertSame([], $collection::times(2)->sliding(3, 2)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(3)->sliding(3, 2)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(4)->sliding(3, 2)->toArray()); + $this->assertSame( + [[1, 2, 3], [3, 4, 5]], + $collection::times(5)->sliding(3, 2)->map->values()->toArray() + ); + $this->assertSame( + [[1, 2, 3], [3, 4, 5]], + $collection::times(6)->sliding(3, 2)->map->values()->toArray() + ); + + // Ensure keys are preserved, and inner chunks are also collections + $chunks = $collection::times(3)->sliding(); + + $this->assertSame([[0 => 1, 1 => 2], [1 => 2, 2 => 3]], $chunks->toArray()); + + $this->assertInstanceOf($collection, $chunks); + $this->assertInstanceOf($collection, $chunks->first()); + $this->assertInstanceOf($collection, $chunks->skip(1)->first()); } /** @@ -207,9 +488,87 @@ class SupportCollectionTest extends TestCase { $data = new $collection([1, 2, 3, 4, 5, 6]); - $data = $data->skip(4)->values(); + // Total items to skip is smaller than collection length + $this->assertSame([5, 6], $data->skip(4)->values()->all()); - $this->assertSame([5, 6], $data->all()); + // Total items to skip is more than collection length + $this->assertSame([], $data->skip(10)->values()->all()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSkipUntil($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3, 4, 4]); + + // Item at the beginning of the collection + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipUntil(1)->values()->all()); + + // Item at the middle of the collection + $this->assertSame([3, 3, 4, 4], $data->skipUntil(3)->values()->all()); + + // Item not in the collection + $this->assertSame([], $data->skipUntil(5)->values()->all()); + + // Item at the beginning of the collection + $data = $data->skipUntil(function ($value, $key) { + return $value <= 1; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item at the middle of the collection + $data = $data->skipUntil(function ($value, $key) { + return $value >= 3; + })->values(); + + $this->assertSame([3, 3, 4, 4], $data->all()); + + // Item not in the collection + $data = $data->skipUntil(function ($value, $key) { + return $value >= 5; + })->values(); + + $this->assertSame([], $data->all()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSkipWhile($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3, 4, 4]); + + // Item at the beginning of the collection + $this->assertSame([2, 2, 3, 3, 4, 4], $data->skipWhile(1)->values()->all()); + + // Item not in the collection + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipWhile(5)->values()->all()); + + // Item in the collection but not at the beginning + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipWhile(2)->values()->all()); + + // Item not in the collection + $data = $data->skipWhile(function ($value, $key) { + return $value >= 5; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item in the collection but not at the beginning + $data = $data->skipWhile(function ($value, $key) { + return $value >= 2; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item at the beginning of the collection + $data = $data->skipWhile(function ($value, $key) { + return $value < 3; + })->values(); + + $this->assertSame([3, 3, 4, 4], $data->all()); } /** @@ -295,7 +654,7 @@ class SupportCollectionTest extends TestCase */ public function testToJsonEncodesTheJsonSerializeResult($collection) { - $c = $this->getMockBuilder($collection)->setMethods(['jsonSerialize'])->getMock(); + $c = $this->getMockBuilder($collection)->onlyMethods(['jsonSerialize'])->getMock(); $c->expects($this->once())->method('jsonSerialize')->willReturn('foo'); $results = $c->toJson(); $this->assertJsonStringEqualsJsonString(json_encode('foo'), $results); @@ -306,7 +665,7 @@ class SupportCollectionTest extends TestCase */ public function testCastingToStringJsonEncodesTheToArrayResult($collection) { - $c = $this->getMockBuilder($collection)->setMethods(['jsonSerialize'])->getMock(); + $c = $this->getMockBuilder($collection)->onlyMethods(['jsonSerialize'])->getMock(); $c->expects($this->once())->method('jsonSerialize')->willReturn('foo'); $this->assertJsonStringEqualsJsonString(json_encode('foo'), (string) $c); @@ -327,10 +686,31 @@ class SupportCollectionTest extends TestCase public function testArrayAccessOffsetExists() { - $c = new Collection(['foo', 'bar']); + $c = new Collection(['foo', 'bar', null]); $this->assertTrue($c->offsetExists(0)); $this->assertTrue($c->offsetExists(1)); - $this->assertFalse($c->offsetExists(1000)); + $this->assertFalse($c->offsetExists(2)); + } + + public function testBehavesLikeAnArrayWithArrayAccess() + { + // indexed array + $input = ['foo', null]; + $c = new Collection($input); + $this->assertEquals(isset($input[0]), isset($c[0])); // existing value + $this->assertEquals(isset($input[1]), isset($c[1])); // existing but null value + $this->assertEquals(isset($input[1000]), isset($c[1000])); // non-existing value + $this->assertEquals($input[0], $c[0]); + $this->assertEquals($input[1], $c[1]); + + // associative array + $input = ['k1' => 'foo', 'k2' => null]; + $c = new Collection($input); + $this->assertEquals(isset($input['k1']), isset($c['k1'])); // existing value + $this->assertEquals(isset($input['k2']), isset($c['k2'])); // existing but null value + $this->assertEquals(isset($input['k3']), isset($c['k3'])); // non-existing value + $this->assertEquals($input['k1'], $c['k1']); + $this->assertEquals($input['k2'], $c['k2']); } public function testArrayAccessOffsetGet() @@ -400,7 +780,7 @@ class SupportCollectionTest extends TestCase /** * @dataProvider collectionClassProvider */ - public function testCountableByWithoutPredicate($collection) + public function testCountByStandalone($collection) { $c = new $collection(['foo', 'foo', 'foo', 'bar', 'bar', 'foobar']); $this->assertEquals(['foo' => 3, 'bar' => 2, 'foobar' => 1], $c->countBy()->all()); @@ -415,7 +795,19 @@ class SupportCollectionTest extends TestCase /** * @dataProvider collectionClassProvider */ - public function testCountableByWithPredicate($collection) + public function testCountByWithKey($collection) + { + $c = new $collection([ + ['key' => 'a'], ['key' => 'a'], ['key' => 'a'], ['key' => 'a'], + ['key' => 'b'], ['key' => 'b'], ['key' => 'b'], + ]); + $this->assertEquals(['a' => 4, 'b' => 3], $c->countBy('key')->all()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testCountableByWithCallback($collection) { $c = new $collection(['alice', 'aaron', 'bob', 'carla']); $this->assertEquals(['a' => 2, 'b' => 1, 'c' => 1], $c->countBy(function ($name) { @@ -428,6 +820,16 @@ class SupportCollectionTest extends TestCase })->all()); } + /** + * @dataProvider collectionClassProvider + */ + public function testContainsOneItem($collection) + { + $this->assertFalse((new $collection([]))->containsOneItem()); + $this->assertTrue((new $collection([1]))->containsOneItem()); + $this->assertFalse((new $collection([1, 2]))->containsOneItem()); + } + public function testIterable() { $c = new Collection(['foo']); @@ -459,7 +861,7 @@ class SupportCollectionTest extends TestCase $c = new $collection(['id' => 1, 'first' => 'Hello', 'second' => 'World']); $this->assertEquals(['first' => 'Hello', 'second' => 'World'], $c->filter(function ($item, $key) { - return $key != 'id'; + return $key !== 'id'; })->all()); } @@ -495,7 +897,8 @@ class SupportCollectionTest extends TestCase public function testHigherOrderFilter($collection) { $c = new $collection([ - new class { + new class + { public $name = 'Alex'; public function active() @@ -503,7 +906,8 @@ class SupportCollectionTest extends TestCase return true; } }, - new class { + new class + { public $name = 'John'; public function active() @@ -653,8 +1057,10 @@ class SupportCollectionTest extends TestCase */ public function testWhereInstanceOf($collection) { - $c = new $collection([new stdClass, new stdClass, new $collection, new stdClass]); + $c = new $collection([new stdClass, new stdClass, new $collection, new stdClass, new Str]); $this->assertCount(3, $c->whereInstanceOf(stdClass::class)); + + $this->assertCount(4, $c->whereInstanceOf([stdClass::class, Str::class])); } /** @@ -999,7 +1405,7 @@ class SupportCollectionTest extends TestCase $c1 = new $collection(['id' => 1, 'first_word' => 'Hello']); $c2 = new $collection(['ID' => 123, 'foo_bar' => 'Hello']); // demonstrate that diffKeys wont support case insensitivity - $this->assertEquals(['id'=>1, 'first_word'=> 'Hello'], $c1->diffKeys($c2)->all()); + $this->assertEquals(['id' => 1, 'first_word' => 'Hello'], $c1->diffKeys($c2)->all()); // allow for case insensitive difference $this->assertEquals(['first_word' => 'Hello'], $c1->diffKeysUsing($c2, 'strcasecmp')->all()); } @@ -1345,6 +1751,33 @@ class SupportCollectionTest extends TestCase $data = (new $collection(['foo', 'bar-10', 'bar-1']))->sort(); $this->assertEquals(['bar-1', 'bar-10', 'foo'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sort(); + $this->assertEquals(['T1', 'T10', 'T2'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sort(SORT_NATURAL); + $this->assertEquals(['T1', 'T2', 'T10'], $data->values()->all()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testSortDesc($collection) + { + $data = (new $collection([5, 3, 1, 2, 4]))->sortDesc(); + $this->assertEquals([5, 4, 3, 2, 1], $data->values()->all()); + + $data = (new $collection([-1, -3, -2, -4, -5, 0, 5, 3, 1, 2, 4]))->sortDesc(); + $this->assertEquals([5, 4, 3, 2, 1, 0, -1, -2, -3, -4, -5], $data->values()->all()); + + $data = (new $collection(['bar-1', 'foo', 'bar-10']))->sortDesc(); + $this->assertEquals(['foo', 'bar-10', 'bar-1'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sortDesc(); + $this->assertEquals(['T2', 'T10', 'T1'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sortDesc(SORT_NATURAL); + $this->assertEquals(['T10', 'T2', 'T1'], $data->values()->all()); } /** @@ -1399,6 +1832,17 @@ class SupportCollectionTest extends TestCase $this->assertEquals([['name' => 'dayle'], ['name' => 'taylor']], array_values($data->all())); } + /** + * @dataProvider collectionClassProvider + */ + public function testSortByCallableString($collection) + { + $data = new $collection([['sort' => 2], ['sort' => 1]]); + $data = $data->sortBy([['sort', 'asc']]); + + $this->assertEquals([['sort' => 1], ['sort' => 2]], array_values($data->all())); + } + /** * @dataProvider collectionClassProvider */ @@ -1439,6 +1883,16 @@ class SupportCollectionTest extends TestCase $this->assertSame(['b' => 'dayle', 'a' => 'taylor'], $data->sortKeysDesc()->all()); } + /** + * @dataProvider collectionClassProvider + */ + public function testSortKeysUsing($collection) + { + $data = new $collection(['B' => 'dayle', 'a' => 'taylor']); + + $this->assertSame(['a' => 'taylor', 'B' => 'dayle'], $data->sortKeysUsing('strnatcasecmp')->all()); + } + /** * @dataProvider collectionClassProvider */ @@ -1505,6 +1959,58 @@ class SupportCollectionTest extends TestCase ); } + /** + * @dataProvider collectionClassProvider + */ + public function testSplitIn($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $data = $data->splitIn(3); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertCount(3, $data); + $this->assertEquals([1, 2, 3, 4], $data->get(0)->values()->toArray()); + $this->assertEquals([5, 6, 7, 8], $data->get(1)->values()->toArray()); + $this->assertEquals([9, 10], $data->get(2)->values()->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testChunkWhileOnEqualElements($collection) + { + $data = (new $collection(['A', 'A', 'B', 'B', 'C', 'C', 'C'])) + ->chunkWhile(function ($current, $key, $chunk) { + return $chunk->last() === $current; + }); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals([0 => 'A', 1 => 'A'], $data->first()->toArray()); + $this->assertEquals([2 => 'B', 3 => 'B'], $data->get(1)->toArray()); + $this->assertEquals([4 => 'C', 5 => 'C', 6 => 'C'], $data->last()->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testChunkWhileOnContiguouslyIncreasingIntegers($collection) + { + $data = (new $collection([1, 4, 9, 10, 11, 12, 15, 16, 19, 20, 21])) + ->chunkWhile(function ($current, $key, $chunk) { + return $chunk->last() + 1 == $current; + }); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals([0 => 1], $data->first()->toArray()); + $this->assertEquals([1 => 4], $data->get(1)->toArray()); + $this->assertEquals([2 => 9, 3 => 10, 4 => 11, 5 => 12], $data->get(2)->toArray()); + $this->assertEquals([6 => 15, 7 => 16], $data->get(3)->toArray()); + $this->assertEquals([8 => 19, 9 => 20, 10 => 21], $data->last()->toArray()); + } + /** * @dataProvider collectionClassProvider */ @@ -1597,6 +2103,20 @@ class SupportCollectionTest extends TestCase $this->assertFalse($data->has(['third', 'first'])); } + /** + * @dataProvider collectionClassProvider + */ + public function testHasAny($collection) + { + $data = new $collection(['id' => 1, 'first' => 'Hello', 'second' => 'World']); + + $this->assertTrue($data->hasAny('first')); + $this->assertFalse($data->hasAny('third')); + $this->assertTrue($data->hasAny(['first', 'second'])); + $this->assertTrue($data->hasAny(['first', 'fourth'])); + $this->assertFalse($data->hasAny(['third', 'fourth'])); + } + /** * @dataProvider collectionClassProvider */ @@ -1609,6 +2129,17 @@ class SupportCollectionTest extends TestCase $data = new $collection(['taylor', 'dayle']); $this->assertSame('taylordayle', $data->implode('')); $this->assertSame('taylor,dayle', $data->implode(',')); + + $data = new $collection([ + ['name' => Str::of('taylor'), 'email' => Str::of('foo')], + ['name' => Str::of('dayle'), 'email' => Str::of('bar')], + ]); + $this->assertSame('foobar', $data->implode('email')); + $this->assertSame('foo,bar', $data->implode('email', ',')); + + $data = new $collection([Str::of('taylor'), Str::of('dayle')]); + $this->assertSame('taylordayle', $data->implode('')); + $this->assertSame('taylor,dayle', $data->implode(',')); } /** @@ -1621,6 +2152,37 @@ class SupportCollectionTest extends TestCase $this->assertEquals(['taylor', 'dayle'], $data->all()); } + public function testGetOrPut() + { + $data = new Collection(['name' => 'taylor', 'email' => 'foo']); + + $this->assertEquals('taylor', $data->getOrPut('name', null)); + $this->assertEquals('foo', $data->getOrPut('email', null)); + $this->assertEquals('male', $data->getOrPut('gender', 'male')); + + $this->assertEquals('taylor', $data->get('name')); + $this->assertEquals('foo', $data->get('email')); + $this->assertEquals('male', $data->get('gender')); + + $data = new Collection(['name' => 'taylor', 'email' => 'foo']); + + $this->assertEquals('taylor', $data->getOrPut('name', function () { + return null; + })); + + $this->assertEquals('foo', $data->getOrPut('email', function () { + return null; + })); + + $this->assertEquals('male', $data->getOrPut('gender', function () { + return 'male'; + })); + + $this->assertEquals('taylor', $data->get('name')); + $this->assertEquals('foo', $data->get('email')); + $this->assertEquals('male', $data->get('gender')); + } + public function testPut() { $data = new Collection(['name' => 'taylor', 'email' => 'foo']); @@ -1697,6 +2259,131 @@ class SupportCollectionTest extends TestCase $this->assertEquals([1 => 'dayle', 2 => 'shawn'], $data->all()); } + /** + * @dataProvider collectionClassProvider + */ + public function testTakeUntilUsingValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeUntil(3); + + $this->assertSame([1, 2], $data->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeUntilUsingCallback($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeUntil(function ($item) { + return $item >= 3; + }); + + $this->assertSame([1, 2], $data->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeUntilReturnsAllItemsForUnmetValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $actual = $data->takeUntil(99); + + $this->assertSame($data->toArray(), $actual->toArray()); + + $actual = $data->takeUntil(function ($item) { + return $item >= 99; + }); + + $this->assertSame($data->toArray(), $actual->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeUntilCanBeProxied($collection) + { + $data = new $collection([ + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Taylor'), + new TestSupportCollectionHigherOrderItem('Jason'), + ]); + + $actual = $data->takeUntil->is('Jason'); + + $this->assertCount(2, $actual); + $this->assertSame('Adam', $actual->get(0)->name); + $this->assertSame('Taylor', $actual->get(1)->name); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeWhileUsingValue($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3]); + + $data = $data->takeWhile(1); + + $this->assertSame([1, 1], $data->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeWhileUsingCallback($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeWhile(function ($item) { + return $item < 3; + }); + + $this->assertSame([1, 2], $data->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeWhileReturnsNoItemsForUnmetValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $actual = $data->takeWhile(2); + + $this->assertSame([], $actual->toArray()); + + $actual = $data->takeWhile(function ($item) { + return $item == 99; + }); + + $this->assertSame([], $actual->toArray()); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testTakeWhileCanBeProxied($collection) + { + $data = new $collection([ + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Taylor'), + new TestSupportCollectionHigherOrderItem('Taylor'), + ]); + + $actual = $data->takeWhile->is('Adam'); + + $this->assertCount(2, $actual); + $this->assertSame('Adam', $actual->get(0)->name); + $this->assertSame('Adam', $actual->get(1)->name); + } + /** * @dataProvider collectionClassProvider */ @@ -1863,6 +2550,16 @@ class SupportCollectionTest extends TestCase $this->assertSame('foo', $collection::unwrap('foo')); } + /** + * @dataProvider collectionClassProvider + */ + public function testEmptyMethod($collection) + { + $collection = $collection::empty(); + + $this->assertCount(0, $collection->all()); + } + /** * @dataProvider collectionClassProvider */ @@ -1888,6 +2585,42 @@ class SupportCollectionTest extends TestCase $this->assertEquals(range(1, 5), $range->all()); } + /** + * @dataProvider collectionClassProvider + */ + public function testRangeMethod($collection) + { + $this->assertSame( + [1, 2, 3, 4, 5], + $collection::range(1, 5)->all() + ); + + $this->assertSame( + [-2, -1, 0, 1, 2], + $collection::range(-2, 2)->all() + ); + + $this->assertSame( + [-4, -3, -2], + $collection::range(-4, -2)->all() + ); + + $this->assertSame( + [5, 4, 3, 2, 1], + $collection::range(5, 1)->all() + ); + + $this->assertSame( + [2, 1, 0, -1, -2], + $collection::range(2, -2)->all() + ); + + $this->assertSame( + [-2, -3, -4], + $collection::range(-2, -4)->all() + ); + } + /** * @dataProvider collectionClassProvider */ @@ -1968,6 +2701,14 @@ class SupportCollectionTest extends TestCase $cut = $data->splice(1, 1, 'bar'); $this->assertEquals(['foo', 'bar'], $data->all()); $this->assertEquals(['baz'], $cut->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 0, ['bar']); + $this->assertEquals(['foo', 'bar', 'baz'], $data->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 0, new Collection(['bar'])); + $this->assertEquals(['foo', 'bar', 'baz'], $data->all()); } /** @@ -2219,6 +2960,8 @@ class SupportCollectionTest extends TestCase $this->assertEquals(['b', 'f'], $data->nth(4, 1)->all()); $this->assertEquals(['c'], $data->nth(4, 2)->all()); $this->assertEquals(['d'], $data->nth(4, 3)->all()); + $this->assertEquals(['c', 'e'], $data->nth(2, 2)->all()); + $this->assertEquals(['c', 'd', 'e', 'f'], $data->nth(1, 2)->all()); } /** @@ -2740,7 +3483,7 @@ class SupportCollectionTest extends TestCase $c = new $collection(['foo', 'bar']); $this->assertEquals(['foo'], $c->reject(function ($v) { - return $v == 'bar'; + return $v === 'bar'; })->values()->all()); $c = new $collection(['foo', null]); @@ -2751,12 +3494,12 @@ class SupportCollectionTest extends TestCase $c = new $collection(['foo', 'bar']); $this->assertEquals(['foo', 'bar'], $c->reject(function ($v) { - return $v == 'baz'; + return $v === 'baz'; })->values()->all()); $c = new $collection(['id' => 1, 'primary' => 'foo', 'secondary' => 'bar']); $this->assertEquals(['primary' => 'foo', 'secondary' => 'bar'], $c->reject(function ($item, $key) { - return $key == 'id'; + return $key === 'id'; })->all()); } @@ -2829,7 +3572,7 @@ class SupportCollectionTest extends TestCase return $value < 1 && is_numeric($value); })); $this->assertFalse($c->search(function ($value) { - return $value == 'nope'; + return $value === 'nope'; })); } @@ -2857,10 +3600,67 @@ class SupportCollectionTest extends TestCase public function testPrepend() { $c = new Collection(['one', 'two', 'three', 'four']); - $this->assertEquals(['zero', 'one', 'two', 'three', 'four'], $c->prepend('zero')->all()); + $this->assertEquals( + ['zero', 'one', 'two', 'three', 'four'], + $c->prepend('zero')->all() + ); + + $c = new Collection(['one' => 1, 'two' => 2]); + $this->assertEquals( + ['zero' => 0, 'one' => 1, 'two' => 2], + $c->prepend(0, 'zero')->all() + ); $c = new Collection(['one' => 1, 'two' => 2]); - $this->assertEquals(['zero' => 0, 'one' => 1, 'two' => 2], $c->prepend(0, 'zero')->all()); + $this->assertEquals( + [null => 0, 'one' => 1, 'two' => 2], + $c->prepend(0, null)->all() + ); + } + + public function testPushWithOneItem() + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => ['a', 'b', 'c'], + 4 => ['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe'], + 5 => 'Jonny from Laroe', + ]; + + $data = new Collection([4, 5, 6]); + $data->push(['a', 'b', 'c']); + $data->push(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe']); + $actual = $data->push('Jonny from Laroe')->toArray(); + + $this->assertSame($expected, $actual); + } + + public function testPushWithMultipleItems() + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => 'Jonny', + 4 => 'from', + 5 => 'Laroe', + 6 => 'Jonny', + 7 => 'from', + 8 => 'Laroe', + 9 => 'a', + 10 => 'b', + 11 => 'c', + ]; + + $data = new Collection([4, 5, 6]); + $data->push('Jonny', 'from', 'Laroe'); + $data->push(...[11 => 'Jonny', 12 => 'from', 13 => 'Laroe']); + $data->push(...collect(['a', 'b', 'c'])); + $actual = $data->push(...[])->toArray(); + + $this->assertSame($expected, $actual); } /** @@ -3132,6 +3932,24 @@ class SupportCollectionTest extends TestCase $this->assertSame($expected, $actual); } + /** + * @dataProvider collectionClassProvider + */ + public function testDump($collection) + { + $log = new Collection; + + VarDumper::setHandler(function ($value) use ($log) { + $log->add($value); + }); + + (new $collection([1, 2, 3]))->dump('one', 'two'); + + $this->assertSame(['one', 'two', [1, 2, 3]], $log->all()); + + VarDumper::setHandler(null); + } + /** * @dataProvider collectionClassProvider */ @@ -3141,6 +3959,62 @@ class SupportCollectionTest extends TestCase $this->assertEquals(6, $data->reduce(function ($carry, $element) { return $carry += $element; })); + + $data = new $collection([ + 'foo' => 'bar', + 'baz' => 'qux', + ]); + $this->assertSame('foobarbazqux', $data->reduce(function ($carry, $element, $key) { + return $carry .= $key.$element; + })); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testReduceWithKeys($collection) + { + $data = new $collection([ + 'foo' => 'bar', + 'baz' => 'qux', + ]); + $this->assertSame('foobarbazqux', $data->reduceWithKeys(function ($carry, $element, $key) { + return $carry .= $key.$element; + })); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testReduceSpread($collection) + { + $data = new $collection([-1, 0, 1, 2, 3, 4, 5]); + + [$sum, $max, $min] = $data->reduceSpread(function ($sum, $max, $min, $value) { + $sum += $value; + $max = max($max, $value); + $min = min($min, $value); + + return [$sum, $max, $min]; + }, 0, PHP_INT_MIN, PHP_INT_MAX); + + $this->assertEquals(14, $sum); + $this->assertEquals(5, $max); + $this->assertEquals(-1, $min); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testReduceSpreadThrowsAnExceptionIfReducerDoesNotReturnAnArray($collection) + { + $data = new $collection([1]); + + $this->expectException(UnexpectedValueException::class); + + $data->reduceSpread(function () { + return false; + }, null); } /** @@ -3166,6 +4040,39 @@ class SupportCollectionTest extends TestCase })); } + /** + * @dataProvider collectionClassProvider + */ + public function testPipeInto($collection) + { + $data = new $collection([ + 'first', 'second', + ]); + + $instance = $data->pipeInto(TestCollectionMapIntoObject::class); + + $this->assertSame($data, $instance->value); + } + + /** + * @dataProvider collectionClassProvider + */ + public function testPipeThrough($collection) + { + $data = new $collection([1, 2, 3]); + + $result = $data->pipeThrough([ + function ($data) { + return $data->merge([4, 5]); + }, + function ($data) { + return $data->sum(); + }, + ]); + + $this->assertEquals(15, $result); + } + /** * @dataProvider collectionClassProvider */ @@ -3351,6 +4258,26 @@ class SupportCollectionTest extends TestCase $this->assertEquals(['foo' => 1, 'bar' => 2, 'baz' => 3], $data->toArray()); } + /** + * @dataProvider collectionClassProvider + * @requires PHP >= 8.1 + */ + public function testCollectionFromEnum($collection) + { + $data = new $collection(TestEnum::A); + $this->assertEquals([TestEnum::A], $data->toArray()); + } + + /** + * @dataProvider collectionClassProvider + * @requires PHP >= 8.1 + */ + public function testCollectionFromBackedEnum($collection) + { + $data = new $collection(TestBackedEnum::A); + $this->assertEquals([TestBackedEnum::A], $data->toArray()); + } + /** * @dataProvider collectionClassProvider */ @@ -3581,27 +4508,27 @@ class SupportCollectionTest extends TestCase [$tims, $others] = $data->partition('name', 'Tim')->all(); - $this->assertEquals($tims->values()->all(), [ + $this->assertEquals([ ['name' => 'Tim', 'age' => 17], ['name' => 'Tim', 'age' => 41], - ]); + ], $tims->values()->all()); - $this->assertEquals($others->values()->all(), [ + $this->assertEquals([ ['name' => 'Agatha', 'age' => 62], ['name' => 'Kristina', 'age' => 33], - ]); + ], $others->values()->all()); [$adults, $minors] = $data->partition('age', '>=', 18)->all(); - $this->assertEquals($adults->values()->all(), [ + $this->assertEquals([ ['name' => 'Agatha', 'age' => 62], ['name' => 'Kristina', 'age' => 33], ['name' => 'Tim', 'age' => 41], - ]); + ], $adults->values()->all()); - $this->assertEquals($minors->values()->all(), [ + $this->assertEquals([ ['name' => 'Tim', 'age' => 17], - ]); + ], $minors->values()->all()); } /** @@ -4025,6 +4952,42 @@ class SupportCollectionTest extends TestCase ], $data->all()); } + /** + * @dataProvider collectionClassProvider + */ + public function testUndot($collection) + { + $data = $collection::make([ + 'name' => 'Taylor', + 'meta.foo' => 'bar', + 'meta.baz' => 'boom', + 'meta.bam.boom' => 'bip', + ])->undot(); + $this->assertSame([ + 'name' => 'Taylor', + 'meta' => [ + 'foo' => 'bar', + 'baz' => 'boom', + 'bam' => [ + 'boom' => 'bip', + ], + ], + ], $data->all()); + + $data = $collection::make([ + 'foo.0' => 'bar', + 'foo.1' => 'baz', + 'foo.baz' => 'boom', + ])->undot(); + $this->assertSame([ + 'foo' => [ + 'bar', + 'baz', + 'baz' => 'boom', + ], + ], $data->all()); + } + /** * Provides each collection class, respectively. * @@ -4052,6 +5015,11 @@ class TestSupportCollectionHigherOrderItem { return $this->name = strtoupper($this->name); } + + public function is($name) + { + return $this->name === $name; + } } class TestAccessorEloquentTestStub @@ -4099,21 +5067,25 @@ class TestArrayAccessImplementation implements ArrayAccess $this->arr = $arr; } + #[\ReturnTypeWillChange] public function offsetExists($offset) { return isset($this->arr[$offset]); } + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->arr[$offset]; } + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { $this->arr[$offset] = $value; } + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->arr[$offset]); @@ -4138,7 +5110,7 @@ class TestJsonableObject implements Jsonable class TestJsonSerializeObject implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return ['foo' => 'bar']; } @@ -4146,7 +5118,7 @@ class TestJsonSerializeObject implements JsonSerializable class TestJsonSerializeWithScalarValueObject implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): string { return 'foo'; } diff --git a/tests/Support/SupportComposerTest.php b/tests/Support/SupportComposerTest.php index 3d21348820129e13702fff26b54ae010f202a9ba..273f51c468a20a794fd9b4b2f412946eecf3cdde 100755 --- a/tests/Support/SupportComposerTest.php +++ b/tests/Support/SupportComposerTest.php @@ -59,7 +59,7 @@ class SupportComposerTest extends TestCase $process->shouldReceive('run')->once(); $composer = $this->getMockBuilder(Composer::class) - ->setMethods(['getProcess']) + ->onlyMethods(['getProcess']) ->setConstructorArgs([$files, $directory]) ->getMock(); $composer->expects($this->once()) diff --git a/tests/Support/SupportFacadeTest.php b/tests/Support/SupportFacadeTest.php index 0daa265d5fb4770bbce187b72cfc5c2ee94c1c31..914a9f2bb37b0aa486eb032ae33270803fa551b7 100755 --- a/tests/Support/SupportFacadeTest.php +++ b/tests/Support/SupportFacadeTest.php @@ -94,22 +94,23 @@ class ApplicationStub implements ArrayAccess $this->attributes[$key] = $instance; } - public function offsetExists($offset) + public function offsetExists($offset): bool { return isset($this->attributes[$offset]); } + #[\ReturnTypeWillChange] public function offsetGet($key) { return $this->attributes[$key]; } - public function offsetSet($key, $value) + public function offsetSet($key, $value): void { $this->attributes[$key] = $value; } - public function offsetUnset($key) + public function offsetUnset($key): void { unset($this->attributes[$key]); } diff --git a/tests/Support/SupportFacadesEventTest.php b/tests/Support/SupportFacadesEventTest.php index c7acd9fb53fcb29616603d6d4fbafbf83955c99c..15c4a4acc7abb405837f79a813369f3aed334a92 100644 --- a/tests/Support/SupportFacadesEventTest.php +++ b/tests/Support/SupportFacadesEventTest.php @@ -24,7 +24,7 @@ class SupportFacadesEventTest extends TestCase { parent::setUp(); - $this->events = m::spy(Dispatcher::class); + $this->events = m::mock(Dispatcher::class); $container = new Container; $container->instance('events', $this->events); @@ -38,6 +38,7 @@ class SupportFacadesEventTest extends TestCase protected function tearDown(): void { Event::clearResolvedInstances(); + Event::setFacadeApplication(null); m::close(); } diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php index ffd137df67756312ec3eccd9ce38074cafad4fd2..1f37ee26e850f976adb1eec7a78ce136fe9b8b24 100755 --- a/tests/Support/SupportFluentTest.php +++ b/tests/Support/SupportFluentTest.php @@ -64,7 +64,7 @@ class SupportFluentTest extends TestCase $fluent = new Fluent(['attributes' => '1']); $this->assertTrue(isset($fluent['attributes'])); - $this->assertEquals($fluent['attributes'], 1); + $this->assertEquals(1, $fluent['attributes']); $fluent->attributes(); @@ -107,7 +107,7 @@ class SupportFluentTest extends TestCase public function testToJsonEncodesTheToArrayResult() { - $fluent = $this->getMockBuilder(Fluent::class)->setMethods(['toArray'])->getMock(); + $fluent = $this->getMockBuilder(Fluent::class)->onlyMethods(['toArray'])->getMock(); $fluent->expects($this->once())->method('toArray')->willReturn('foo'); $results = $fluent->toJson(); @@ -124,6 +124,7 @@ class FluentArrayIteratorStub implements IteratorAggregate $this->items = $items; } + #[\ReturnTypeWillChange] public function getIterator() { return new ArrayIterator($this->items); diff --git a/tests/Support/SupportHelpersTest.php b/tests/Support/SupportHelpersTest.php index 9e5b6464f4cafcc7437f6b872f886a619e5039fc..50b702a7c15b9d7037b9d500e513380c01e8c74f 100755 --- a/tests/Support/SupportHelpersTest.php +++ b/tests/Support/SupportHelpersTest.php @@ -6,6 +6,7 @@ use ArrayAccess; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Env; use Illuminate\Support\Optional; +use LogicException; use Mockery as m; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -39,6 +40,9 @@ class SupportHelpersTest extends TestCase $this->assertSame('foo', value(function () { return 'foo'; })); + $this->assertSame('foo', value(function ($arg) { + return $arg; + }, 'foo')); } public function testObjectGet() @@ -362,10 +366,63 @@ class SupportHelpersTest extends TestCase } public function testThrow() + { + $this->expectException(LogicException::class); + + throw_if(true, new LogicException); + } + + public function testThrowDefaultException() + { + $this->expectException(RuntimeException::class); + + throw_if(true); + } + + public function testThrowExceptionWithMessage() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('test'); + + throw_if(true, 'test'); + } + + public function testThrowExceptionAsStringWithMessage() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('test'); + + throw_if(true, LogicException::class, 'test'); + } + + public function testThrowUnless() + { + $this->expectException(LogicException::class); + + throw_unless(false, new LogicException); + } + + public function testThrowUnlessDefaultException() { $this->expectException(RuntimeException::class); - throw_if(true, new RuntimeException); + throw_unless(false); + } + + public function testThrowUnlessExceptionWithMessage() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('test'); + + throw_unless(false, 'test'); + } + + public function testThrowUnlessExceptionAsStringWithMessage() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('test'); + + throw_unless(false, LogicException::class, 'test'); } public function testThrowReturnIfNotThrown() @@ -385,7 +442,8 @@ class SupportHelpersTest extends TestCase { $this->assertNull(optional(null)->something()); - $this->assertEquals(10, optional(new class { + $this->assertEquals(10, optional(new class + { public function something() { return 10; @@ -463,10 +521,12 @@ class SupportHelpersTest extends TestCase $this->assertNull(optional(null)->present()->something()); - $this->assertSame('$10.00', optional(new class { + $this->assertSame('$10.00', optional(new class + { public function present() { - return new class { + return new class + { public function something() { return '$10.00'; @@ -492,7 +552,28 @@ class SupportHelpersTest extends TestCase $this->assertEquals(2, $attempts); // Make sure we waited 100ms for the first attempt - $this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.02); + $this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.03); + } + + public function testRetryWithPassingSleepCallback() + { + $startTime = microtime(true); + + $attempts = retry(3, function ($attempts) { + if ($attempts > 2) { + return $attempts; + } + + throw new RuntimeException; + }, function ($attempt) { + return $attempt * 100; + }); + + // Make sure we made three attempts + $this->assertEquals(3, $attempts); + + // Make sure we waited 300ms for the first two attempts + $this->assertEqualsWithDelta(0.3, microtime(true) - $startTime, 0.03); } public function testRetryWithPassingWhenCallback() @@ -513,7 +594,7 @@ class SupportHelpersTest extends TestCase $this->assertEquals(2, $attempts); // Make sure we waited 100ms for the first attempt - $this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.02); + $this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.03); } public function testRetryWithFailingWhenCallback() @@ -637,11 +718,11 @@ class SupportHelpersTest extends TestCase $this->assertSame('x"null"x', env('foo')); } - public function testGetFromENVFirst() + public function testGetFromSERVERFirst() { $_ENV['foo'] = 'From $_ENV'; $_SERVER['foo'] = 'From $_SERVER'; - $this->assertSame('From $_ENV', env('foo')); + $this->assertSame('From $_SERVER', env('foo')); } public function providesPregReplaceArrayData() @@ -663,7 +744,9 @@ class SupportHelpersTest extends TestCase ]; } - /** @dataProvider providesPregReplaceArrayData */ + /** + * @dataProvider providesPregReplaceArrayData + */ public function testPregReplaceArray($pattern, $replacements, $subject, $expectedOutput) { $this->assertSame( @@ -712,22 +795,23 @@ class SupportTestArrayAccess implements ArrayAccess $this->attributes = $attributes; } - public function offsetExists($offset) + public function offsetExists($offset): bool { return array_key_exists($offset, $this->attributes); } + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->attributes[$offset]; } - public function offsetSet($offset, $value) + public function offsetSet($offset, $value): void { $this->attributes[$offset] = $value; } - public function offsetUnset($offset) + public function offsetUnset($offset): void { unset($this->attributes[$offset]); } diff --git a/tests/Support/SupportHtmlStringTest.php b/tests/Support/SupportHtmlStringTest.php index 6610e63404798689d3e4014e85278d9abf6e4919..8ed846e17bfbd6c1cf881c95507cb42976fca9cb 100644 --- a/tests/Support/SupportHtmlStringTest.php +++ b/tests/Support/SupportHtmlStringTest.php @@ -20,4 +20,14 @@ class SupportHtmlStringTest extends TestCase $html = new HtmlString('<h1>foo</h1>'); $this->assertEquals($str, (string) $html); } + + public function testIsEmpty() + { + $this->assertTrue((new HtmlString(''))->isEmpty()); + } + + public function testIsNotEmpty() + { + $this->assertTrue((new HtmlString('foo'))->isNotEmpty()); + } } diff --git a/tests/Support/SupportJsTest.php b/tests/Support/SupportJsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3b19e97216627a7dfe96cb2a692ad50a8c040a1d --- /dev/null +++ b/tests/Support/SupportJsTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Illuminate\Tests\Support; + +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Support\Js; +use JsonSerializable; +use PHPUnit\Framework\TestCase; + +class SupportJsTest extends TestCase +{ + public function testScalars() + { + $this->assertEquals('false', (string) Js::from(false)); + $this->assertEquals('true', (string) Js::from(true)); + $this->assertEquals('1', (string) Js::from(1)); + $this->assertEquals('1.1', (string) Js::from(1.1)); + $this->assertEquals( + "'\\u003Cdiv class=\\u0022foo\\u0022\\u003E\\u0027quoted html\\u0027\\u003C\\/div\\u003E'", + (string) Js::from('<div class="foo">\'quoted html\'</div>') + ); + } + + public function testArrays() + { + $this->assertEquals( + "JSON.parse('[\\u0022hello\\u0022,\\u0022world\\u0022]')", + (string) Js::from(['hello', 'world']) + ); + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from(['foo' => 'hello', 'bar' => 'world']) + ); + } + + public function testObjects() + { + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from((object) ['foo' => 'hello', 'bar' => 'world']) + ); + } + + public function testJsonSerializable() + { + // JsonSerializable should take precedence over Arrayable, so we'll + // implement both and make sure the correct data is used. + $data = new class() implements JsonSerializable, Arrayable + { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function jsonSerialize() + { + return ['foo' => 'hello', 'bar' => 'world']; + } + + public function toArray() + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testJsonable() + { + // Jsonable should take precedence over JsonSerializable and Arrayable, so we'll + // implement all three and make sure the correct data is used. + $data = new class() implements Jsonable, JsonSerializable, Arrayable + { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function toJson($options = 0) + { + return json_encode(['foo' => 'hello', 'bar' => 'world'], $options); + } + + public function jsonSerialize() + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + + public function toArray() + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testArrayable() + { + $data = new class() implements Arrayable + { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function toArray() + { + return ['foo' => 'hello', 'bar' => 'world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } +} diff --git a/tests/Support/SupportLazyCollectionIsLazyTest.php b/tests/Support/SupportLazyCollectionIsLazyTest.php index ac43df18c20cd1573ae4ab6333433bdc8b38787f..14ff3f49278a33a95c52ac32f163d9a5265c41e2 100644 --- a/tests/Support/SupportLazyCollectionIsLazyTest.php +++ b/tests/Support/SupportLazyCollectionIsLazyTest.php @@ -2,12 +2,42 @@ namespace Illuminate\Tests\Support; +use Exception; +use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\LazyCollection; +use Illuminate\Support\MultipleItemsFoundException; use PHPUnit\Framework\TestCase; use stdClass; class SupportLazyCollectionIsLazyTest extends TestCase { + use Concerns\CountsEnumerations; + + public function testMakeWithClosureIsLazy() + { + [$closure, $recorder] = $this->makeGeneratorFunctionWithRecorder(); + + LazyCollection::make($closure); + + $this->assertEquals([], $recorder->all()); + } + + public function testMakeWithLazyCollectionIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + LazyCollection::make($collection); + }); + } + + public function testMakeWithGeneratorIsNotLazy() + { + [$closure, $recorder] = $this->makeGeneratorFunctionWithRecorder(5); + + LazyCollection::make($closure()); + + $this->assertEquals([1, 2, 3, 4, 5], $recorder->all()); + } + public function testEagerEnumeratesOnce() { $this->assertEnumeratesOnce(function ($collection) { @@ -29,6 +59,29 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testChunkWhileIsLazy() + { + $collection = LazyCollection::make(['A', 'A', 'B', 'B', 'C', 'C', 'C']); + + $this->assertDoesNotEnumerateCollection($collection, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + }); + }); + + $this->assertEnumeratesCollection($collection, 3, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + })->first(); + }); + + $this->assertEnumeratesCollectionOnce($collection, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + })->all(); + }); + } + public function testCollapseIsLazy() { $collection = LazyCollection::make([ @@ -416,6 +469,25 @@ class SupportLazyCollectionIsLazyTest extends TestCase $this->assertEnumerates(5, function ($collection) { $collection->has(4); }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->has('non-existent'); + }); + } + + public function testHasAnyIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->hasAny(4); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->hasAny([1, 4]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->hasAny(['non', 'existent']); + }); } public function testImplodeEnumeratesOnce() @@ -461,6 +533,13 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testContainsOneItemIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + $collection->containsOneItem(); + }); + } + public function testJoinIsLazy() { $this->assertEnumeratesOnce(function ($collection) { @@ -739,8 +818,16 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } - public function testReduceEnumeratesOnce() + public function testReduceIsLazy() { + $this->assertEnumerates(1, function ($collection) { + $this->rescue(function () use ($collection) { + $collection->reduce(function ($total, $value) { + throw new Exception('Short-circuit'); + }, 0); + }); + }); + $this->assertEnumeratesOnce(function ($collection) { $collection->reduce(function ($total, $value) { return $total + $value; @@ -748,6 +835,23 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testReduceSpreadIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $this->rescue(function () use ($collection) { + $collection->reduceSpread(function ($one, $two, $value) { + throw new Exception('Short-circuit'); + }, 0, 0); + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->reduceSpread(function ($total, $max, $value) { + return [$total + $value, max($max, $value)]; + }, 0, 0); + }); + } + public function testRejectIsLazy() { $this->assertDoesNotEnumerate(function ($collection) { @@ -839,6 +943,29 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testSlidingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sliding(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->sliding()->take(1)->all(); + }); + + $this->assertEnumerates(3, function ($collection) { + $collection->sliding()->take(2)->all(); + }); + + $this->assertEnumerates(13, function ($collection) { + $collection->sliding(3, 5)->take(3)->all(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sliding()->all(); + }); + } + public function testSkipIsLazy() { $this->assertDoesNotEnumerate(function ($collection) { @@ -850,6 +977,40 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testSkipUntilIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->skipUntil(INF); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipUntil(10)->first(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipUntil(function ($item) { + return $item === 10; + })->first(); + }); + } + + public function testSkipWhileIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->skipWhile(1); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->skipWhile(1)->first(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipWhile(function ($item) { + return $item < 10; + })->first(); + }); + } + public function testSliceIsLazy() { $this->assertDoesNotEnumerate(function ($collection) { @@ -871,6 +1032,35 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testFindFirstOrFailIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->firstOrFail(); + }); + + $this->assertEnumerates(1, function ($collection) { + $collection->firstOrFail(function ($item) { + return $item === 1; + }); + }); + + $this->assertEnumerates(100, function ($collection) { + try { + $collection->firstOrFail(function ($item) { + return $item === 101; + }); + } catch (ItemNotFoundException $e) { + // + } + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->firstOrFail(function ($item) { + return $item % 2 === 0; + }); + }); + } + public function testSomeIsLazy() { $this->assertEnumerates(5, function ($collection) { @@ -886,6 +1076,33 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testSoleIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + try { + $collection->sole(); + } catch (MultipleItemsFoundException $e) { + // + } + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sole(function ($item) { + return $item === 1; + }); + }); + + $this->assertEnumerates(4, function ($collection) { + try { + $collection->sole(function ($item) { + return $item % 2 === 0; + }); + } catch (MultipleItemsFoundException $e) { + // + } + }); + } + public function testSortIsLazy() { $this->assertDoesNotEnumerate(function ($collection) { @@ -897,6 +1114,17 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testSortDescIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortDesc(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortDesc()->all(); + }); + } + public function testSortByIsLazy() { $this->assertDoesNotEnumerate(function ($collection) { @@ -978,6 +1206,40 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testTakeUntilIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->takeUntil(INF); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeUntil(10)->all(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeUntil(function ($item) { + return $item === 10; + })->all(); + }); + } + + public function testTakeWhileIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->takeWhile(0); + }); + + $this->assertEnumerates(1, function ($collection) { + $collection->takeWhile(0)->all(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeWhile(function ($item) { + return $item < 10; + })->all(); + }); + } + public function testTapDoesNotEnumerate() { $this->assertDoesNotEnumerate(function ($collection) { @@ -1245,6 +1507,52 @@ class SupportLazyCollectionIsLazyTest extends TestCase }); } + public function testWhereNotNullIsLazy() + { + $data = $this->make([['a' => 1], ['a' => null], ['a' => 2], ['a' => 3]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotNull('a'); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNotNull('a')->all(); + }); + + $data = $this->make([1, null, 2, null, 3]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotNull(); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNotNull()->all(); + }); + } + + public function testWhereNullIsLazy() + { + $data = $this->make([['a' => 1], ['a' => null], ['a' => 2], ['a' => 3]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNull('a'); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNull('a')->all(); + }); + + $data = $this->make([1, null, 2, null, 3]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNull(); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNull()->all(); + }); + } + public function testWhereStrictIsLazy() { $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); @@ -1292,76 +1600,12 @@ class SupportLazyCollectionIsLazyTest extends TestCase return new LazyCollection($source); } - protected function assertDoesNotEnumerate(callable $executor) + protected function rescue($callback) { - $this->assertEnumerates(0, $executor); - } - - protected function assertDoesNotEnumerateCollection( - LazyCollection $collection, - callable $executor - ) { - $this->assertEnumeratesCollection($collection, 0, $executor); - } - - protected function assertEnumerates($count, callable $executor) - { - $this->assertEnumeratesCollection( - LazyCollection::times(100), - $count, - $executor - ); - } - - protected function assertEnumeratesCollection( - LazyCollection $collection, - $count, - callable $executor - ) { - $enumerated = 0; - - $data = $this->countEnumerations($collection, $enumerated); - - $executor($data); - - $this->assertEnumerations($count, $enumerated); - } - - protected function assertEnumeratesOnce(callable $executor) - { - $this->assertEnumeratesCollectionOnce(LazyCollection::times(10), $executor); - } - - protected function assertEnumeratesCollectionOnce( - LazyCollection $collection, - callable $executor - ) { - $enumerated = 0; - $count = $collection->count(); - $collection = $this->countEnumerations($collection, $enumerated); - - $executor($collection); - - $this->assertEquals( - $count, - $enumerated, - $count > $enumerated ? 'Failed to enumerate in full.' : 'Enumerated more than once.' - ); - } - - protected function assertEnumerations($expected, $actual) - { - $this->assertEquals( - $expected, - $actual, - "Failed asserting that {$actual} items that were enumerated matches expected {$expected}." - ); - } - - protected function countEnumerations(LazyCollection $collection, &$count) - { - return $collection->tapEach(function () use (&$count) { - $count++; - }); + try { + $callback(); + } catch (Exception $e) { + // Silence is golden + } } } diff --git a/tests/Support/SupportLazyCollectionTest.php b/tests/Support/SupportLazyCollectionTest.php index 7c4773e44b6e17f47bdb176180f6cd536d6c306e..f3007934a80a5b9e167da0fcb1c832a2d0b5838f 100644 --- a/tests/Support/SupportLazyCollectionTest.php +++ b/tests/Support/SupportLazyCollectionTest.php @@ -2,8 +2,10 @@ namespace Illuminate\Tests\Support; +use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; +use Mockery as m; use PHPUnit\Framework\TestCase; class SupportLazyCollectionTest extends TestCase @@ -154,6 +156,36 @@ class SupportLazyCollectionTest extends TestCase $this->assertSame([['key', 1], ['key', 2]], $results); } + public function testTakeUntilTimeout() + { + $timeout = Carbon::now(); + + $mock = m::mock(LazyCollection::class.'[now]'); + + $results = $mock + ->times(10) + ->pipe(function ($collection) use ($mock, $timeout) { + tap($collection) + ->mockery_init($mock->mockery_getContainer()) + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('now') + ->times(3) + ->andReturn( + (clone $timeout)->sub(2, 'minute')->getTimestamp(), + (clone $timeout)->sub(1, 'minute')->getTimestamp(), + $timeout->getTimestamp() + ); + + return $collection; + }) + ->takeUntilTimeout($timeout) + ->all(); + + $this->assertSame([1, 2], $results); + + m::close(); + } + public function testTapEach() { $data = LazyCollection::times(10); @@ -171,4 +203,13 @@ class SupportLazyCollectionTest extends TestCase $this->assertSame([1, 2, 3, 4, 5], $data); $this->assertSame([1, 2, 3, 4, 5], $tapped); } + + public function testUniqueDoubleEnumeration() + { + $data = LazyCollection::times(2)->unique(); + + $data->all(); + + $this->assertSame([1, 2], $data->all()); + } } diff --git a/tests/Support/SupportMacroableTest.php b/tests/Support/SupportMacroableTest.php index 6745d7aaeec67ef4aa26b2cae03a34f3b3356833..afab0ee20279ac26c5883f279cb97d3cceaf2a38 100644 --- a/tests/Support/SupportMacroableTest.php +++ b/tests/Support/SupportMacroableTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Support; +use BadMethodCallException; use Illuminate\Support\Traits\Macroable; use PHPUnit\Framework\TestCase; @@ -73,6 +74,23 @@ class SupportMacroableTest extends TestCase TestMacroable::mixin(new TestMixin); $this->assertSame('foo', $instance->methodThree()); } + + public function testFlushMacros() + { + TestMacroable::macro('flushMethod', function () { + return 'flushMethod'; + }); + + $instance = new TestMacroable; + + $this->assertSame('flushMethod', $instance->flushMethod()); + + TestMacroable::flushMacros(); + + $this->expectException(BadMethodCallException::class); + + $instance->flushMethod(); + } } class EmptyMacroable diff --git a/tests/Support/SupportMessageBagTest.php b/tests/Support/SupportMessageBagTest.php index 1a76e9a8ee85114402bdbe54622181360fac4fd4..4c443713c1513be63ce5c6c1eede8fa22ede24d7 100755 --- a/tests/Support/SupportMessageBagTest.php +++ b/tests/Support/SupportMessageBagTest.php @@ -4,16 +4,10 @@ namespace Illuminate\Tests\Support; use Illuminate\Support\Collection; use Illuminate\Support\MessageBag; -use Mockery as m; use PHPUnit\Framework\TestCase; class SupportMessageBagTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testUniqueness() { $container = new MessageBag; diff --git a/tests/Support/SupportNamespacedItemResolverTest.php b/tests/Support/SupportNamespacedItemResolverTest.php index 0295a1e483fd8b5b3ae2b7b8af7e8d5b094ff3f9..df06b424fc1719334dba121edd79b697ffee2334 100755 --- a/tests/Support/SupportNamespacedItemResolverTest.php +++ b/tests/Support/SupportNamespacedItemResolverTest.php @@ -19,11 +19,24 @@ class SupportNamespacedItemResolverTest extends TestCase public function testParsedItemsAreCached() { - $r = $this->getMockBuilder(NamespacedItemResolver::class)->setMethods(['parseBasicSegments', 'parseNamespacedSegments'])->getMock(); + $r = $this->getMockBuilder(NamespacedItemResolver::class)->onlyMethods(['parseBasicSegments', 'parseNamespacedSegments'])->getMock(); $r->setParsedKey('foo.bar', ['foo']); $r->expects($this->never())->method('parseBasicSegments'); $r->expects($this->never())->method('parseNamespacedSegments'); $this->assertEquals(['foo'], $r->parseKey('foo.bar')); } + + public function testParsedItemsMayBeFlushed() + { + $r = $this->getMockBuilder(NamespacedItemResolver::class)->onlyMethods(['parseBasicSegments', 'parseNamespacedSegments'])->getMock(); + $r->expects($this->once())->method('parseBasicSegments')->will( + $this->returnValue(['bar']) + ); + + $r->setParsedKey('foo.bar', ['foo']); + $r->flushParsedKeys(); + + $this->assertEquals(['bar'], $r->parseKey('foo.bar')); + } } diff --git a/tests/Support/SupportPluralizerTest.php b/tests/Support/SupportPluralizerTest.php index 5e4f4298ccef2cb0a184ea4d15f6bbdc3f1c89c0..d528a6006759e01637acf331ce532ece3c67c52b 100755 --- a/tests/Support/SupportPluralizerTest.php +++ b/tests/Support/SupportPluralizerTest.php @@ -16,6 +16,8 @@ class SupportPluralizerTest extends TestCase { $this->assertSame('children', Str::plural('child')); $this->assertSame('cod', Str::plural('cod')); + $this->assertSame('The words', Str::plural('The word')); + $this->assertSame('Bouquetés', Str::plural('Bouqueté')); } public function testCaseSensitiveSingularUsage() @@ -68,6 +70,49 @@ class SupportPluralizerTest extends TestCase $this->assertPluralStudly('RealHumans', 'RealHuman', -2); } + public function testPluralNotAppliedForStringEndingWithNonAlphanumericCharacter() + { + $this->assertSame('Alien.', Str::plural('Alien.')); + $this->assertSame('Alien!', Str::plural('Alien!')); + $this->assertSame('Alien ', Str::plural('Alien ')); + $this->assertSame('50%', Str::plural('50%')); + } + + public function testPluralAppliedForStringEndingWithNumericCharacter() + { + $this->assertSame('User1s', Str::plural('User1')); + $this->assertSame('User2s', Str::plural('User2')); + $this->assertSame('User3s', Str::plural('User3')); + } + + public function testPluralSupportsArrays() + { + $this->assertSame('users', Str::plural('user', [])); + $this->assertSame('user', Str::plural('user', ['one'])); + $this->assertSame('users', Str::plural('user', ['one', 'two'])); + } + + public function testPluralSupportsCollections() + { + $this->assertSame('users', Str::plural('user', collect())); + $this->assertSame('user', Str::plural('user', collect(['one']))); + $this->assertSame('users', Str::plural('user', collect(['one', 'two']))); + } + + public function testPluralStudlySupportsArrays() + { + $this->assertPluralStudly('SomeUsers', 'SomeUser', []); + $this->assertPluralStudly('SomeUser', 'SomeUser', ['one']); + $this->assertPluralStudly('SomeUsers', 'SomeUser', ['one', 'two']); + } + + public function testPluralStudlySupportsCollections() + { + $this->assertPluralStudly('SomeUsers', 'SomeUser', collect()); + $this->assertPluralStudly('SomeUser', 'SomeUser', collect(['one'])); + $this->assertPluralStudly('SomeUsers', 'SomeUser', collect(['one', 'two'])); + } + private function assertPluralStudly($expected, $value, $count = 2) { $this->assertSame($expected, Str::pluralStudly($value, $count)); diff --git a/tests/Support/SupportReflectorTest.php b/tests/Support/SupportReflectorTest.php index df5b3e414e465fa7f6f2f96c8e3d5d7c80ee995b..9e4dfda394571cfd2126b4388926348637b30ff8 100644 --- a/tests/Support/SupportReflectorTest.php +++ b/tests/Support/SupportReflectorTest.php @@ -48,8 +48,15 @@ class SupportReflectorTest extends TestCase $this->assertSame(A::class, Reflector::getParameterClassName($method->getParameters()[0])); } + public function testParameterSubclassOfInterface() + { + $method = (new ReflectionClass(TestClassWithInterfaceSubclassParameter::class))->getMethod('f'); + + $this->assertTrue(Reflector::isParameterSubclassOf($method->getParameters()[0], IA::class)); + } + /** - * @requires PHP 8 + * @requires PHP >= 8 */ public function testUnionTypeName() { @@ -81,6 +88,7 @@ class B extends A { public function f(parent $x) { + // } } @@ -92,15 +100,16 @@ class C { public function f(A|Model $x) { + // } -}' - ); +}'); } class TestClassWithCall { public function __call($method, $parameters) { + // } } @@ -108,5 +117,22 @@ class TestClassWithCallStatic { public static function __callStatic($method, $parameters) { + // + } +} + +interface IA +{ +} + +interface IB extends IA +{ +} + +class TestClassWithInterfaceSubclassParameter +{ + public function f(IB $x) + { + // } } diff --git a/tests/Support/SupportReflectsClosuresTest.php b/tests/Support/SupportReflectsClosuresTest.php new file mode 100644 index 0000000000000000000000000000000000000000..be8cba697c215b1b9a4982118b91cc491ce6e72f --- /dev/null +++ b/tests/Support/SupportReflectsClosuresTest.php @@ -0,0 +1,144 @@ +<?php + +namespace Illuminate\Tests\Support; + +use Illuminate\Support\Traits\ReflectsClosures; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +class SupportReflectsClosuresTest extends TestCase +{ + public function testReflectsClosures() + { + $this->assertParameterTypes([ExampleParameter::class], function (ExampleParameter $one) { + // assert the Closure isn't actually executed + throw new RuntimeException; + }); + + $this->assertParameterTypes([], function () { + // + }); + + $this->assertParameterTypes([null], function ($one) { + // + }); + + $this->assertParameterTypes([null, ExampleParameter::class], function ($one, ExampleParameter $two = null) { + // + }); + + $this->assertParameterTypes([null, ExampleParameter::class], function (string $one, ?ExampleParameter $two) { + // + }); + + // Because the parameter is variadic, the closure will always receive an array. + $this->assertParameterTypes([null], function (ExampleParameter ...$vars) { + // + }); + } + + public function testItReturnsTheFirstParameterType() + { + $type = ReflectsClosuresClass::reflectFirst(function (ExampleParameter $a) { + // + }); + + $this->assertInstanceOf($type, new ExampleParameter); + } + + public function testItThrowsWhenNoParameters() + { + $this->expectException(RuntimeException::class); + + ReflectsClosuresClass::reflectFirst(function () { + // + }); + } + + public function testItThrowsWhenNoFirstParameterType() + { + $this->expectException(RuntimeException::class); + + ReflectsClosuresClass::reflectFirst(function ($a, ExampleParameter $b) { + // + }); + } + + /** + * @requires PHP >= 8 + */ + public function testItWorksWithUnionTypes() + { + $types = ReflectsClosuresClass::reflectFirstAll(function (ExampleParameter $a, $b) { + // + }); + + $this->assertEquals([ + ExampleParameter::class, + ], $types); + + $closure = require __DIR__.'/Fixtures/UnionTypesClosure.php'; + + $types = ReflectsClosuresClass::reflectFirstAll($closure); + + $this->assertEquals([ + ExampleParameter::class, + AnotherExampleParameter::class, + ], $types); + } + + public function testItWorksWithUnionTypesWithNoTypeHints() + { + $this->expectException(RuntimeException::class); + + $types = ReflectsClosuresClass::reflectFirstAll(function ($a, $b) { + // + }); + } + + public function testItWorksWithUnionTypesWithNoArguments() + { + $this->expectException(RuntimeException::class); + + $types = ReflectsClosuresClass::reflectFirstAll(function () { + // + }); + } + + private function assertParameterTypes($expected, $closure) + { + $types = ReflectsClosuresClass::reflect($closure); + + $this->assertSame($expected, $types); + } +} + +class ReflectsClosuresClass +{ + use ReflectsClosures; + + public static function reflect($closure) + { + return array_values((new static)->closureParameterTypes($closure)); + } + + public static function reflectFirst($closure) + { + return (new static)->firstClosureParameterType($closure); + } + + public static function reflectFirstAll($closure) + { + return (new static)->firstClosureParameterTypes($closure); + } +} + +class ExampleParameter +{ + // +} + +class AnotherExampleParameter +{ + // +} diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 74eeaf965aaa46f759fabac819aca3057744947d..486da8e069994267a5f7a10b8680029af3abe52a 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Support; use Illuminate\Support\Str; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\UuidInterface; +use ReflectionClass; class SupportStrTest extends TestCase { @@ -15,6 +16,14 @@ class SupportStrTest extends TestCase $this->assertSame('Taylor Otwell', Str::words('Taylor Otwell', 3)); } + public function testStringCanBeLimitedByWordsNonAscii() + { + $this->assertSame('这是...', Str::words('这是 段中文', 1)); + $this->assertSame('这是___', Str::words('这是 段中文', 1, '___')); + $this->assertSame('这是-段中文', Str::words('这是-段中文', 3, '___')); + $this->assertSame('这是___', Str::words('这是 段中文', 1, '___')); + } + public function testStringTrimmedOnlyWhereNecessary() { $this->assertSame(' Taylor Otwell ', Str::words(' Taylor Otwell ', 3)); @@ -27,6 +36,36 @@ class SupportStrTest extends TestCase $this->assertSame('Jefferson Costella', Str::title('jefFErson coSTella')); } + public function testStringHeadline() + { + $this->assertSame('Jefferson Costella', Str::headline('jefferson costella')); + $this->assertSame('Jefferson Costella', Str::headline('jefFErson coSTella')); + $this->assertSame('Jefferson Costella Uses Laravel', Str::headline('jefferson_costella uses-_Laravel')); + $this->assertSame('Jefferson Costella Uses Laravel', Str::headline('jefferson_costella uses__Laravel')); + + $this->assertSame('Laravel P H P Framework', Str::headline('laravel_p_h_p_framework')); + $this->assertSame('Laravel P H P Framework', Str::headline('laravel _p _h _p _framework')); + $this->assertSame('Laravel Php Framework', Str::headline('laravel_php_framework')); + $this->assertSame('Laravel Ph P Framework', Str::headline('laravel-phP-framework')); + $this->assertSame('Laravel Php Framework', Str::headline('laravel -_- php -_- framework ')); + + $this->assertSame('Foo Bar', Str::headline('fooBar')); + $this->assertSame('Foo Bar', Str::headline('foo_bar')); + $this->assertSame('Foo Bar Baz', Str::headline('foo-barBaz')); + $this->assertSame('Foo Bar Baz', Str::headline('foo-bar_baz')); + + $this->assertSame('Öffentliche Überraschungen', Str::headline('öffentliche-überraschungen')); + $this->assertSame('Öffentliche Überraschungen', Str::headline('-_öffentliche_überraschungen_-')); + $this->assertSame('Öffentliche Überraschungen', Str::headline('-öffentliche überraschungen')); + + $this->assertSame('Sind Öde Und So', Str::headline('sindÖdeUndSo')); + + $this->assertSame('Orwell 1984', Str::headline('orwell 1984')); + $this->assertSame('Orwell 1984', Str::headline('orwell 1984')); + $this->assertSame('Orwell 1984', Str::headline('-orwell-1984 -')); + $this->assertSame('Orwell 1984', Str::headline(' orwell_- 1984 ')); + } + public function testStringWithoutWordsDoesntProduceError() { $nbsp = chr(0xC2).chr(0xA0); @@ -42,8 +81,8 @@ class SupportStrTest extends TestCase public function testStringAsciiWithSpecificLocale() { - $this->assertSame('h H sht SHT a A y Y', Str::ascii('х Х щ Щ ъ Ъ ь Ь', 'bg')); - $this->assertSame('ae oe ue AE OE UE', Str::ascii('ä ö ü Ä Ö Ü', 'de')); + $this->assertSame('h H sht Sht a A ia yo', Str::ascii('х Х щ Щ ъ Ъ иа йо', 'bg')); + $this->assertSame('ae oe ue Ae Oe Ue', Str::ascii('ä ö ü Ä Ö Ü', 'de')); } public function testStartsWith() @@ -54,7 +93,13 @@ class SupportStrTest extends TestCase $this->assertTrue(Str::startsWith('jason', ['day', 'jas'])); $this->assertFalse(Str::startsWith('jason', 'day')); $this->assertFalse(Str::startsWith('jason', ['day'])); + $this->assertFalse(Str::startsWith('jason', null)); + $this->assertFalse(Str::startsWith('jason', [null])); + $this->assertFalse(Str::startsWith('0123', [null])); + $this->assertTrue(Str::startsWith('0123', 0)); + $this->assertFalse(Str::startsWith('jason', 'J')); $this->assertFalse(Str::startsWith('jason', '')); + $this->assertFalse(Str::startsWith('', '')); $this->assertFalse(Str::startsWith('7', ' 7')); $this->assertTrue(Str::startsWith('7a', '7')); $this->assertTrue(Str::startsWith('7a', 7)); @@ -68,6 +113,9 @@ class SupportStrTest extends TestCase $this->assertTrue(Str::startsWith('Malmö', 'Malmö')); $this->assertFalse(Str::startsWith('Jönköping', 'Jonko')); $this->assertFalse(Str::startsWith('Malmö', 'Malmo')); + $this->assertTrue(Str::startsWith('你好', '你')); + $this->assertFalse(Str::startsWith('你好', '好')); + $this->assertFalse(Str::startsWith('你好', 'a')); } public function testEndsWith() @@ -79,6 +127,10 @@ class SupportStrTest extends TestCase $this->assertFalse(Str::endsWith('jason', 'no')); $this->assertFalse(Str::endsWith('jason', ['no'])); $this->assertFalse(Str::endsWith('jason', '')); + $this->assertFalse(Str::endsWith('', '')); + $this->assertFalse(Str::endsWith('jason', [null])); + $this->assertFalse(Str::endsWith('jason', null)); + $this->assertFalse(Str::endsWith('jason', 'N')); $this->assertFalse(Str::endsWith('7', ' 7')); $this->assertTrue(Str::endsWith('a7', '7')); $this->assertTrue(Str::endsWith('a7', 7)); @@ -92,6 +144,9 @@ class SupportStrTest extends TestCase $this->assertTrue(Str::endsWith('Malmö', 'mö')); $this->assertFalse(Str::endsWith('Jönköping', 'oping')); $this->assertFalse(Str::endsWith('Malmö', 'mo')); + $this->assertTrue(Str::endsWith('你好', '好')); + $this->assertFalse(Str::endsWith('你好', '你')); + $this->assertFalse(Str::endsWith('你好', 'a')); } public function testStrBefore() @@ -119,6 +174,21 @@ class SupportStrTest extends TestCase $this->assertSame('yv2et', Str::beforeLast('yv2et2te', 2)); } + public function testStrBetween() + { + $this->assertSame('abc', Str::between('abc', '', 'c')); + $this->assertSame('abc', Str::between('abc', 'a', '')); + $this->assertSame('abc', Str::between('abc', '', '')); + $this->assertSame('b', Str::between('abc', 'a', 'c')); + $this->assertSame('b', Str::between('dddabc', 'a', 'c')); + $this->assertSame('b', Str::between('abcddd', 'a', 'c')); + $this->assertSame('b', Str::between('dddabcddd', 'a', 'c')); + $this->assertSame('nn', Str::between('hannah', 'ha', 'ah')); + $this->assertSame('a]ab[b', Str::between('[a]ab[b]', '[', ']')); + $this->assertSame('foo', Str::between('foofoobar', 'foo', 'bar')); + $this->assertSame('bar', Str::between('foobarbar', 'foo', 'bar')); + } + public function testStrAfter() { $this->assertSame('nah', Str::after('hannah', 'han')); @@ -154,6 +224,7 @@ class SupportStrTest extends TestCase $this->assertFalse(Str::contains('taylor', 'xxx')); $this->assertFalse(Str::contains('taylor', ['xxx'])); $this->assertFalse(Str::contains('taylor', '')); + $this->assertFalse(Str::contains('', '')); } public function testStrContainsAll() @@ -190,6 +261,22 @@ class SupportStrTest extends TestCase $this->assertSame('/test/string', Str::start('//test/string', '/')); } + public function testFlushCache() + { + $reflection = new ReflectionClass(Str::class); + $property = $reflection->getProperty('snakeCache'); + $property->setAccessible(true); + + Str::flushCache(); + $this->assertEmpty($property->getValue()); + + Str::snake('Taylor Otwell'); + $this->assertNotEmpty($property->getValue()); + + Str::flushCache(); + $this->assertEmpty($property->getValue()); + } + public function testFinish() { $this->assertSame('abbc', Str::finish('ab', 'bc')); @@ -230,8 +317,12 @@ class SupportStrTest extends TestCase $this->assertTrue(Str::is('foo/bar/baz', $valueObject)); $this->assertTrue(Str::is($patternObject, $valueObject)); - //empty patterns + // empty patterns $this->assertFalse(Str::is([], 'test')); + + $this->assertFalse(Str::is('', 0)); + $this->assertFalse(Str::is([null], 0)); + $this->assertTrue(Str::is([null], null)); } /** @@ -296,6 +387,14 @@ class SupportStrTest extends TestCase $this->assertIsString(Str::random()); } + public function testReplace() + { + $this->assertSame('foo bar laravel', Str::replace('baz', 'laravel', 'foo bar baz')); + $this->assertSame('foo bar baz 8.x', Str::replace('?', '8.x', 'foo bar baz ?')); + $this->assertSame('foo/bar/baz', Str::replace(' ', '/', 'foo bar baz')); + $this->assertSame('foo bar baz', Str::replace(['?1', '?2', '?3'], ['foo', 'bar', 'baz'], '?1 ?2 ?3')); + } + public function testReplaceArray() { $this->assertSame('foo/bar/baz', Str::replaceArray('?', ['foo', 'bar', 'baz'], '?/?/?')); @@ -333,6 +432,27 @@ class SupportStrTest extends TestCase $this->assertSame('Malmö Jönköping', Str::replaceLast('', 'yyy', 'Malmö Jönköping')); } + public function testRemove() + { + $this->assertSame('Fbar', Str::remove('o', 'Foobar')); + $this->assertSame('Foo', Str::remove('bar', 'Foobar')); + $this->assertSame('oobar', Str::remove('F', 'Foobar')); + $this->assertSame('Foobar', Str::remove('f', 'Foobar')); + $this->assertSame('oobar', Str::remove('f', 'Foobar', false)); + + $this->assertSame('Fbr', Str::remove(['o', 'a'], 'Foobar')); + $this->assertSame('Fooar', Str::remove(['f', 'b'], 'Foobar')); + $this->assertSame('ooar', Str::remove(['f', 'b'], 'Foobar', false)); + $this->assertSame('Foobar', Str::remove(['f', '|'], 'Foo|bar')); + } + + public function testReverse() + { + $this->assertSame('FooBar', Str::reverse('raBooF')); + $this->assertSame('Teniszütő', Str::reverse('őtüzsineT')); + $this->assertSame('❤MultiByte☆', Str::reverse('☆etyBitluM❤')); + } + public function testSnake() { $this->assertSame('laravel_p_h_p_framework', Str::snake('LaravelPHPFramework')); @@ -364,6 +484,55 @@ class SupportStrTest extends TestCase $this->assertSame('FooBar', Str::studly('foo_bar')); // test cache $this->assertSame('FooBarBaz', Str::studly('foo-barBaz')); $this->assertSame('FooBarBaz', Str::studly('foo-bar_baz')); + + $this->assertSame('ÖffentlicheÜberraschungen', Str::studly('öffentliche-überraschungen')); + } + + public function testMask() + { + $this->assertSame('tay*************', Str::mask('taylor@email.com', '*', 3)); + $this->assertSame('******@email.com', Str::mask('taylor@email.com', '*', 0, 6)); + $this->assertSame('tay*************', Str::mask('taylor@email.com', '*', -13)); + $this->assertSame('tay***@email.com', Str::mask('taylor@email.com', '*', -13, 3)); + + $this->assertSame('****************', Str::mask('taylor@email.com', '*', -17)); + $this->assertSame('*****r@email.com', Str::mask('taylor@email.com', '*', -99, 5)); + + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '*', 16)); + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '*', 16, 99)); + + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '', 3)); + + $this->assertSame('taysssssssssssss', Str::mask('taylor@email.com', 'something', 3)); + $this->assertSame('taysssssssssssss', Str::mask('taylor@email.com', Str::of('something'), 3)); + + $this->assertSame('这是一***', Str::mask('这是一段中文', '*', 3)); + $this->assertSame('**一段中文', Str::mask('这是一段中文', '*', 0, 2)); + + $this->assertSame('ma*n@email.com', Str::mask('maan@email.com', '*', 2, 1)); + $this->assertSame('ma***email.com', Str::mask('maan@email.com', '*', 2, 3)); + $this->assertSame('ma************', Str::mask('maan@email.com', '*', 2)); + + $this->assertSame('mari*@email.com', Str::mask('maria@email.com', '*', 4, 1)); + $this->assertSame('tamar*@email.com', Str::mask('tamara@email.com', '*', 5, 1)); + + $this->assertSame('*aria@email.com', Str::mask('maria@email.com', '*', 0, 1)); + $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1, 1)); + $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1)); + $this->assertSame('***************', Str::mask('maria@email.com', '*', -15)); + $this->assertSame('***************', Str::mask('maria@email.com', '*', 0)); + } + + public function testMatch() + { + $this->assertSame('bar', Str::match('/bar/', 'foo bar')); + $this->assertSame('bar', Str::match('/foo (.*)/', 'foo bar')); + $this->assertEmpty(Str::match('/nothing/', 'foo bar')); + + $this->assertEquals(['bar', 'bar'], Str::matchAll('/bar/', 'bar foo bar')->all()); + + $this->assertEquals(['un', 'ly'], Str::matchAll('/f(\w*)/', 'bar fun bar fly')->all()); + $this->assertEmpty(Str::matchAll('/nothing/', 'bar fun bar fly')); } public function testCamel() @@ -395,6 +564,27 @@ class SupportStrTest extends TestCase $this->assertEmpty(Str::substr('Б', 2)); } + public function testSubstrCount() + { + $this->assertSame(3, Str::substrCount('laravelPHPFramework', 'a')); + $this->assertSame(0, Str::substrCount('laravelPHPFramework', 'z')); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'l', 2)); + $this->assertSame(0, Str::substrCount('laravelPHPFramework', 'z', 2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'k', -1)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'k', -1)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', 1, 2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', 1, 2)); + $this->assertSame(3, Str::substrCount('laravelPHPFramework', 'a', 1, -2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', -10, -3)); + } + + public function testSubstrReplace() + { + $this->assertSame('12:00', Str::substrReplace('1200', ':', 2, 0)); + $this->assertSame('The Laravel Framework', Str::substrReplace('The Framework', 'Laravel ', 4, 0)); + $this->assertSame('Laravel – The PHP Framework for Web Artisans', Str::substrReplace('Laravel Framework', '– The PHP Framework for Web Artisans', 8)); + } + public function testUcfirst() { $this->assertSame('Laravel', Str::ucfirst('laravel')); @@ -403,12 +593,76 @@ class SupportStrTest extends TestCase $this->assertSame('Мама мыла раму', Str::ucfirst('мама мыла раму')); } + public function testUcsplit() + { + $this->assertSame(['Laravel_p_h_p_framework'], Str::ucsplit('Laravel_p_h_p_framework')); + $this->assertSame(['Laravel_', 'P_h_p_framework'], Str::ucsplit('Laravel_P_h_p_framework')); + $this->assertSame(['laravel', 'P', 'H', 'P', 'Framework'], Str::ucsplit('laravelPHPFramework')); + $this->assertSame(['Laravel-ph', 'P-framework'], Str::ucsplit('Laravel-phP-framework')); + + $this->assertSame(['Żółta', 'Łódka'], Str::ucsplit('ŻółtaŁódka')); + $this->assertSame(['sind', 'Öde', 'Und', 'So'], Str::ucsplit('sindÖdeUndSo')); + $this->assertSame(['Öffentliche', 'Überraschungen'], Str::ucsplit('ÖffentlicheÜberraschungen')); + } + public function testUuid() { $this->assertInstanceOf(UuidInterface::class, Str::uuid()); $this->assertInstanceOf(UuidInterface::class, Str::orderedUuid()); } + public function testAsciiNull() + { + $this->assertSame('', Str::ascii(null)); + $this->assertTrue(Str::isAscii(null)); + $this->assertSame('', Str::slug(null)); + } + + public function testPadBoth() + { + $this->assertSame('__Alien___', Str::padBoth('Alien', 10, '_')); + $this->assertSame(' Alien ', Str::padBoth('Alien', 10)); + $this->assertSame(' ❤MultiByte☆ ', Str::padBoth('❤MultiByte☆', 16)); + } + + public function testPadLeft() + { + $this->assertSame('-=-=-Alien', Str::padLeft('Alien', 10, '-=')); + $this->assertSame(' Alien', Str::padLeft('Alien', 10)); + $this->assertSame(' ❤MultiByte☆', Str::padLeft('❤MultiByte☆', 16)); + } + + public function testPadRight() + { + $this->assertSame('Alien-----', Str::padRight('Alien', 10, '-')); + $this->assertSame('Alien ', Str::padRight('Alien', 10)); + $this->assertSame('❤MultiByte☆ ', Str::padRight('❤MultiByte☆', 16)); + } + + public function testSwapKeywords(): void + { + $this->assertSame( + 'PHP 8 is fantastic', + Str::swap([ + 'PHP' => 'PHP 8', + 'awesome' => 'fantastic', + ], 'PHP is awesome') + ); + + $this->assertSame( + 'foo bar baz', + Str::swap([ + 'ⓐⓑ' => 'baz', + ], 'foo bar ⓐⓑ') + ); + } + + public function testWordCount() + { + $this->assertEquals(2, Str::wordCount('Hello, world!')); + $this->assertEquals(10, Str::wordCount('Hi, this is my first contribution to the Laravel framework.')); + } + public function validUuidList() { return [ @@ -440,6 +694,54 @@ class SupportStrTest extends TestCase ['ff6f8cb0-c57da-51e1-9b21-0800200c9a66'], ]; } + + public function testMarkdown() + { + $this->assertSame("<p><em>hello world</em></p>\n", Str::markdown('*hello world*')); + $this->assertSame("<h1>hello world</h1>\n", Str::markdown('# hello world')); + } + + public function testRepeat() + { + $this->assertSame('aaaaa', Str::repeat('a', 5)); + $this->assertSame('', Str::repeat('', 5)); + } + + /** + * @dataProvider specialCharacterProvider + */ + public function testTransliterate(string $value, string $expected): void + { + $this->assertSame($expected, Str::transliterate($value)); + } + + public function specialCharacterProvider(): array + { + return [ + ['ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ', 'abcdefghijklmnopqrstuvwxyz'], + ['⓪①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳', '01234567891011121314151617181920'], + ['⓵⓶⓷⓸⓹⓺⓻⓼⓽⓾', '12345678910'], + ['⓿⓫⓬⓭⓮⓯⓰⓱⓲⓳⓴', '011121314151617181920'], + ['ⓣⓔⓢⓣ@ⓛⓐⓡⓐⓥⓔⓛ.ⓒⓞⓜ', 'test@laravel.com'], + ['🎂', '?'], + ['abcdefghijklmnopqrstuvwxyz', 'abcdefghijklmnopqrstuvwxyz'], + ['0123456789', '0123456789'], + ]; + } + + public function testTransliterateOverrideUnknown(): void + { + $this->assertSame('HHH', Str::transliterate('🎂🚧🏆', 'H')); + $this->assertSame('Hello', Str::transliterate('🎂', 'Hello')); + } + + /** + * @dataProvider specialCharacterProvider + */ + public function testTransliterateStrict(string $value, string $expected): void + { + $this->assertSame($expected, Str::transliterate($value, '?', true)); + } } class StringableObjectStub diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4c813c3d57a5466547a8b5d3ecc283ae9456f7b8 --- /dev/null +++ b/tests/Support/SupportStringableTest.php @@ -0,0 +1,946 @@ +<?php + +namespace Illuminate\Tests\Support; + +use Illuminate\Support\Collection; +use Illuminate\Support\HtmlString; +use Illuminate\Support\Stringable; +use PHPUnit\Framework\TestCase; + +class SupportStringableTest extends TestCase +{ + /** + * @param string $string + * @return \Illuminate\Support\Stringable + */ + protected function stringable($string = '') + { + return new Stringable($string); + } + + public function testClassBasename() + { + $this->assertEquals( + class_basename(static::class), + $this->stringable(static::class)->classBasename() + ); + } + + public function testIsAscii() + { + $this->assertTrue($this->stringable('A')->isAscii()); + $this->assertFalse($this->stringable('ù')->isAscii()); + } + + public function testIsUuid() + { + $this->assertTrue($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->isUuid()); + $this->assertFalse($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->isUuid()); + } + + public function testIsEmpty() + { + $this->assertTrue($this->stringable('')->isEmpty()); + $this->assertFalse($this->stringable('A')->isEmpty()); + $this->assertFalse($this->stringable('0')->isEmpty()); + } + + public function testPluralStudly() + { + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralStudly(1)); + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralStudly(2)); + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralStudly(-1)); + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralStudly(-2)); + } + + public function testMatch() + { + $stringable = $this->stringable('foo bar'); + + $this->assertSame('bar', (string) $stringable->match('/bar/')); + $this->assertSame('bar', (string) $stringable->match('/foo (.*)/')); + $this->assertTrue($stringable->match('/nothing/')->isEmpty()); + + $this->assertEquals(['bar', 'bar'], $this->stringable('bar foo bar')->matchAll('/bar/')->all()); + + $stringable = $this->stringable('bar fun bar fly'); + + $this->assertEquals(['un', 'ly'], $stringable->matchAll('/f(\w*)/')->all()); + $this->assertTrue($stringable->matchAll('/nothing/')->isEmpty()); + } + + public function testTest() + { + $stringable = $this->stringable('foo bar'); + + $this->assertTrue($stringable->test('/bar/')); + $this->assertTrue($stringable->test('/foo (.*)/')); + } + + public function testTrim() + { + $this->assertSame('foo', (string) $this->stringable(' foo ')->trim()); + } + + public function testLtrim() + { + $this->assertSame('foo ', (string) $this->stringable(' foo ')->ltrim()); + } + + public function testRtrim() + { + $this->assertSame(' foo', (string) $this->stringable(' foo ')->rtrim()); + } + + public function testCanBeLimitedByWords() + { + $this->assertSame('Taylor...', (string) $this->stringable('Taylor Otwell')->words(1)); + $this->assertSame('Taylor___', (string) $this->stringable('Taylor Otwell')->words(1, '___')); + $this->assertSame('Taylor Otwell', (string) $this->stringable('Taylor Otwell')->words(3)); + } + + public function testUnless() + { + $this->assertSame('unless false', (string) $this->stringable('unless')->unless(false, function ($stringable, $value) { + return $stringable->append(' false'); + })); + + $this->assertSame('unless true fallbacks to default', (string) $this->stringable('unless')->unless(true, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append(' true fallbacks to default'); + })); + } + + public function testWhenContains() + { + $this->assertSame('Tony Stark', (string) $this->stringable('stark')->whenContains('tar', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + }, function ($stringable) { + return $stringable->prepend('Arno ')->title(); + })); + + $this->assertSame('stark', (string) $this->stringable('stark')->whenContains('xxx', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + })); + + $this->assertSame('Arno Stark', (string) $this->stringable('stark')->whenContains('xxx', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + }, function ($stringable) { + return $stringable->prepend('Arno ')->title(); + })); + } + + public function testWhenContainsAll() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenContainsAll(['tony', 'stark'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenContainsAll(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('TonyStark', (string) $this->stringable('tony stark')->whenContainsAll(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testWhenEndsWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenEndsWith('ark', function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenEndsWith(['kra', 'ark'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenEndsWith(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('TonyStark', (string) $this->stringable('tony stark')->whenEndsWith(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testWhenExactly() + { + $this->assertSame('Nailed it...!', (string) $this->stringable('Tony Stark')->whenExactly('Tony Stark', function ($stringable) { + return 'Nailed it...!'; + }, function ($stringable) { + return 'Swing and a miss...!'; + })); + + $this->assertSame('Swing and a miss...!', (string) $this->stringable('Tony Stark')->whenExactly('Iron Man', function ($stringable) { + return 'Nailed it...!'; + }, function ($stringable) { + return 'Swing and a miss...!'; + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('Tony Stark')->whenExactly('Iron Man', function ($stringable) { + return 'Nailed it...!'; + })); + } + + public function testWhenIs() + { + $this->assertSame('Winner: /', (string) $this->stringable('/')->whenIs('/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('/', (string) $this->stringable('/')->whenIs(' /', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + + $this->assertSame('Try again', (string) $this->stringable('/')->whenIs(' /', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('Winner: foo/bar/baz', (string) $this->stringable('foo/bar/baz')->whenIs('foo/*', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + } + + public function testWhenIsAscii() + { + $this->assertSame('Ascii: A', (string) $this->stringable('A')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + }, function ($stringable) { + return $stringable->prepend('Not Ascii: '); + })); + + $this->assertSame('ù', (string) $this->stringable('ù')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + })); + + $this->assertSame('Not Ascii: ù', (string) $this->stringable('ù')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + }, function ($stringable) { + return $stringable->prepend('Not Ascii: '); + })); + } + + public function testWhenIsUuid() + { + $this->assertSame('Uuid: 2cdc7039-65a6-4ac7-8e5d-d554a98e7b15', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + }, function ($stringable) { + return $stringable->prepend('Not Uuid: '); + })); + + $this->assertSame('2cdc7039-65a6-4ac7-8e5d-d554a98', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + })); + + $this->assertSame('Not Uuid: 2cdc7039-65a6-4ac7-8e5d-d554a98', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + }, function ($stringable) { + return $stringable->prepend('Not Uuid: '); + })); + } + + public function testWhenTest() + { + $this->assertSame('Winner: foo bar', (string) $this->stringable('foo bar')->whenTest('/bar/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('Try again', (string) $this->stringable('foo bar')->whenTest('/link/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('foo bar', (string) $this->stringable('foo bar')->whenTest('/link/', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + } + + public function testWhenStartsWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith('ton', function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith(['ton', 'not'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenStartsWith(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testWhenEmpty() + { + tap($this->stringable(), function ($stringable) { + $this->assertSame($stringable, $stringable->whenEmpty(function () { + // + })); + }); + + $this->assertSame('empty', (string) $this->stringable()->whenEmpty(function () { + return 'empty'; + })); + + $this->assertSame('not-empty', (string) $this->stringable('not-empty')->whenEmpty(function () { + return 'empty'; + })); + } + + public function testWhenNotEmpty() + { + tap($this->stringable(), function ($stringable) { + $this->assertSame($stringable, $stringable->whenNotEmpty(function ($stringable) { + return $stringable.'.'; + })); + }); + + $this->assertSame('', (string) $this->stringable()->whenNotEmpty(function ($stringable) { + return $stringable.'.'; + })); + + $this->assertSame('Not empty.', (string) $this->stringable('Not empty')->whenNotEmpty(function ($stringable) { + return $stringable.'.'; + })); + } + + public function testWhenFalse() + { + $this->assertSame('when', (string) $this->stringable('when')->when(false, function ($stringable, $value) { + return $stringable->append($value)->append('false'); + })); + + $this->assertSame('when false fallbacks to default', (string) $this->stringable('when false ')->when(false, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + })); + } + + public function testWhenTrue() + { + $this->assertSame('when true', (string) $this->stringable('when ')->when(true, function ($stringable) { + return $stringable->append('true'); + })); + + $this->assertSame('gets a value from if', (string) $this->stringable('gets a value ')->when('from if', function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + })); + } + + public function testUnlessTruthy() + { + $this->assertSame('unless', (string) $this->stringable('unless')->unless(1, function ($stringable, $value) { + return $stringable->append($value)->append('true'); + })); + + $this->assertSame('unless true fallbacks to default with value 1', + (string) $this->stringable('unless true ')->unless(1, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable, $value) { + return $stringable->append('fallbacks to default with value ')->append($value); + })); + } + + public function testUnlessFalsy() + { + $this->assertSame('unless 0', (string) $this->stringable('unless ')->unless(0, function ($stringable, $value) { + return $stringable->append($value); + })); + + $this->assertSame('gets the value 0', + (string) $this->stringable('gets the value ')->unless(0, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + })); + } + + public function testTrimmedOnlyWhereNecessary() + { + $this->assertSame(' Taylor Otwell ', (string) $this->stringable(' Taylor Otwell ')->words(3)); + $this->assertSame(' Taylor...', (string) $this->stringable(' Taylor Otwell ')->words(1)); + } + + public function testTitle() + { + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefferson costella')->title()); + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefFErson coSTella')->title()); + } + + public function testWithoutWordsDoesntProduceError() + { + $nbsp = chr(0xC2).chr(0xA0); + $this->assertSame(' ', (string) $this->stringable(' ')->words()); + $this->assertEquals($nbsp, (string) $this->stringable($nbsp)->words()); + } + + public function testAscii() + { + $this->assertSame('@', (string) $this->stringable('@')->ascii()); + $this->assertSame('u', (string) $this->stringable('ü')->ascii()); + } + + public function testAsciiWithSpecificLocale() + { + $this->assertSame('h H sht Sht a A ia yo', (string) $this->stringable('х Х щ Щ ъ Ъ иа йо')->ascii('bg')); + $this->assertSame('ae oe ue Ae Oe Ue', (string) $this->stringable('ä ö ü Ä Ö Ü')->ascii('de')); + } + + public function testStartsWith() + { + $this->assertTrue($this->stringable('jason')->startsWith('jas')); + $this->assertTrue($this->stringable('jason')->startsWith('jason')); + $this->assertTrue($this->stringable('jason')->startsWith(['jas'])); + $this->assertTrue($this->stringable('jason')->startsWith(['day', 'jas'])); + $this->assertFalse($this->stringable('jason')->startsWith('day')); + $this->assertFalse($this->stringable('jason')->startsWith(['day'])); + $this->assertFalse($this->stringable('jason')->startsWith(null)); + $this->assertFalse($this->stringable('jason')->startsWith([null])); + $this->assertFalse($this->stringable('0123')->startsWith([null])); + $this->assertTrue($this->stringable('0123')->startsWith(0)); + $this->assertFalse($this->stringable('jason')->startsWith('J')); + $this->assertFalse($this->stringable('jason')->startsWith('')); + $this->assertFalse($this->stringable('7')->startsWith(' 7')); + $this->assertTrue($this->stringable('7a')->startsWith('7')); + $this->assertTrue($this->stringable('7a')->startsWith(7)); + $this->assertTrue($this->stringable('7.12a')->startsWith(7.12)); + $this->assertFalse($this->stringable('7.12a')->startsWith(7.13)); + $this->assertTrue($this->stringable(7.123)->startsWith('7')); + $this->assertTrue($this->stringable(7.123)->startsWith('7.12')); + $this->assertFalse($this->stringable(7.123)->startsWith('7.13')); + // Test for multibyte string support + $this->assertTrue($this->stringable('Jönköping')->startsWith('Jö')); + $this->assertTrue($this->stringable('Malmö')->startsWith('Malmö')); + $this->assertFalse($this->stringable('Jönköping')->startsWith('Jonko')); + $this->assertFalse($this->stringable('Malmö')->startsWith('Malmo')); + } + + public function testEndsWith() + { + $this->assertTrue($this->stringable('jason')->endsWith('on')); + $this->assertTrue($this->stringable('jason')->endsWith('jason')); + $this->assertTrue($this->stringable('jason')->endsWith(['on'])); + $this->assertTrue($this->stringable('jason')->endsWith(['no', 'on'])); + $this->assertFalse($this->stringable('jason')->endsWith('no')); + $this->assertFalse($this->stringable('jason')->endsWith(['no'])); + $this->assertFalse($this->stringable('jason')->endsWith('')); + $this->assertFalse($this->stringable('jason')->endsWith([null])); + $this->assertFalse($this->stringable('jason')->endsWith(null)); + $this->assertFalse($this->stringable('jason')->endsWith('N')); + $this->assertFalse($this->stringable('7')->endsWith(' 7')); + $this->assertTrue($this->stringable('a7')->endsWith('7')); + $this->assertTrue($this->stringable('a7')->endsWith(7)); + $this->assertTrue($this->stringable('a7.12')->endsWith(7.12)); + $this->assertFalse($this->stringable('a7.12')->endsWith(7.13)); + $this->assertTrue($this->stringable(0.27)->endsWith('7')); + $this->assertTrue($this->stringable(0.27)->endsWith('0.27')); + $this->assertFalse($this->stringable(0.27)->endsWith('8')); + // Test for multibyte string support + $this->assertTrue($this->stringable('Jönköping')->endsWith('öping')); + $this->assertTrue($this->stringable('Malmö')->endsWith('mö')); + $this->assertFalse($this->stringable('Jönköping')->endsWith('oping')); + $this->assertFalse($this->stringable('Malmö')->endsWith('mo')); + } + + public function testBefore() + { + $this->assertSame('han', (string) $this->stringable('hannah')->before('nah')); + $this->assertSame('ha', (string) $this->stringable('hannah')->before('n')); + $this->assertSame('ééé ', (string) $this->stringable('ééé hannah')->before('han')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->before('xxxx')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->before('')); + $this->assertSame('han', (string) $this->stringable('han0nah')->before('0')); + $this->assertSame('han', (string) $this->stringable('han0nah')->before(0)); + $this->assertSame('han', (string) $this->stringable('han2nah')->before(2)); + } + + public function testBeforeLast() + { + $this->assertSame('yve', (string) $this->stringable('yvette')->beforeLast('tte')); + $this->assertSame('yvet', (string) $this->stringable('yvette')->beforeLast('t')); + $this->assertSame('ééé ', (string) $this->stringable('ééé yvette')->beforeLast('yve')); + $this->assertSame('', (string) $this->stringable('yvette')->beforeLast('yve')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->beforeLast('xxxx')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->beforeLast('')); + $this->assertSame('yv0et', (string) $this->stringable('yv0et0te')->beforeLast('0')); + $this->assertSame('yv0et', (string) $this->stringable('yv0et0te')->beforeLast(0)); + $this->assertSame('yv2et', (string) $this->stringable('yv2et2te')->beforeLast(2)); + } + + public function testBetween() + { + $this->assertSame('abc', (string) $this->stringable('abc')->between('', 'c')); + $this->assertSame('abc', (string) $this->stringable('abc')->between('a', '')); + $this->assertSame('abc', (string) $this->stringable('abc')->between('', '')); + $this->assertSame('b', (string) $this->stringable('abc')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabc')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('abcddd')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabcddd')->between('a', 'c')); + $this->assertSame('nn', (string) $this->stringable('hannah')->between('ha', 'ah')); + $this->assertSame('a]ab[b', (string) $this->stringable('[a]ab[b]')->between('[', ']')); + $this->assertSame('foo', (string) $this->stringable('foofoobar')->between('foo', 'bar')); + $this->assertSame('bar', (string) $this->stringable('foobarbar')->between('foo', 'bar')); + } + + public function testAfter() + { + $this->assertSame('nah', (string) $this->stringable('hannah')->after('han')); + $this->assertSame('nah', (string) $this->stringable('hannah')->after('n')); + $this->assertSame('nah', (string) $this->stringable('ééé hannah')->after('han')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->after('xxxx')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->after('')); + $this->assertSame('nah', (string) $this->stringable('han0nah')->after('0')); + $this->assertSame('nah', (string) $this->stringable('han0nah')->after(0)); + $this->assertSame('nah', (string) $this->stringable('han2nah')->after(2)); + } + + public function testAfterLast() + { + $this->assertSame('tte', (string) $this->stringable('yvette')->afterLast('yve')); + $this->assertSame('e', (string) $this->stringable('yvette')->afterLast('t')); + $this->assertSame('e', (string) $this->stringable('ééé yvette')->afterLast('t')); + $this->assertSame('', (string) $this->stringable('yvette')->afterLast('tte')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->afterLast('xxxx')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->afterLast('')); + $this->assertSame('te', (string) $this->stringable('yv0et0te')->afterLast('0')); + $this->assertSame('te', (string) $this->stringable('yv0et0te')->afterLast(0)); + $this->assertSame('te', (string) $this->stringable('yv2et2te')->afterLast(2)); + $this->assertSame('foo', (string) $this->stringable('----foo')->afterLast('---')); + } + + public function testContains() + { + $this->assertTrue($this->stringable('taylor')->contains('ylo')); + $this->assertTrue($this->stringable('taylor')->contains('taylor')); + $this->assertTrue($this->stringable('taylor')->contains(['ylo'])); + $this->assertTrue($this->stringable('taylor')->contains(['xxx', 'ylo'])); + $this->assertFalse($this->stringable('taylor')->contains('xxx')); + $this->assertFalse($this->stringable('taylor')->contains(['xxx'])); + $this->assertFalse($this->stringable('taylor')->contains('')); + } + + public function testContainsAll() + { + $this->assertTrue($this->stringable('taylor otwell')->containsAll(['taylor', 'otwell'])); + $this->assertTrue($this->stringable('taylor otwell')->containsAll(['taylor'])); + $this->assertFalse($this->stringable('taylor otwell')->containsAll(['taylor', 'xxx'])); + } + + public function testParseCallback() + { + $this->assertEquals(['Class', 'method'], $this->stringable('Class@method')->parseCallback('foo')); + $this->assertEquals(['Class', 'foo'], $this->stringable('Class')->parseCallback('foo')); + $this->assertEquals(['Class', null], $this->stringable('Class')->parseCallback()); + } + + public function testSlug() + { + $this->assertSame('hello-world', (string) $this->stringable('hello world')->slug()); + $this->assertSame('hello-world', (string) $this->stringable('hello-world')->slug()); + $this->assertSame('hello-world', (string) $this->stringable('hello_world')->slug()); + $this->assertSame('hello_world', (string) $this->stringable('hello_world')->slug('_')); + $this->assertSame('user-at-host', (string) $this->stringable('user@host')->slug()); + $this->assertSame('سلام-دنیا', (string) $this->stringable('سلام دنیا')->slug('-', null)); + $this->assertSame('sometext', (string) $this->stringable('some text')->slug('')); + $this->assertSame('', (string) $this->stringable('')->slug('')); + $this->assertSame('', (string) $this->stringable('')->slug()); + } + + public function testStart() + { + $this->assertSame('/test/string', (string) $this->stringable('test/string')->start('/')); + $this->assertSame('/test/string', (string) $this->stringable('/test/string')->start('/')); + $this->assertSame('/test/string', (string) $this->stringable('//test/string')->start('/')); + } + + public function testFinish() + { + $this->assertSame('abbc', (string) $this->stringable('ab')->finish('bc')); + $this->assertSame('abbc', (string) $this->stringable('abbcbc')->finish('bc')); + $this->assertSame('abcbbc', (string) $this->stringable('abcbbcbc')->finish('bc')); + } + + public function testIs() + { + $this->assertTrue($this->stringable('/')->is('/')); + $this->assertFalse($this->stringable('/')->is(' /')); + $this->assertFalse($this->stringable('/a')->is('/')); + $this->assertTrue($this->stringable('foo/bar/baz')->is('foo/*')); + + $this->assertTrue($this->stringable('App\Class@method')->is('*@*')); + $this->assertTrue($this->stringable('app\Class@')->is('*@*')); + $this->assertTrue($this->stringable('@method')->is('*@*')); + + // is case sensitive + $this->assertFalse($this->stringable('foo/bar/baz')->is('*BAZ*')); + $this->assertFalse($this->stringable('foo/bar/baz')->is('*FOO*')); + $this->assertFalse($this->stringable('a')->is('A')); + + // Accepts array of patterns + $this->assertTrue($this->stringable('a/')->is(['a*', 'b*'])); + $this->assertTrue($this->stringable('b/')->is(['a*', 'b*'])); + $this->assertFalse($this->stringable('f/')->is(['a*', 'b*'])); + + // numeric values and patterns + $this->assertFalse($this->stringable(123)->is(['a*', 'b*'])); + $this->assertTrue($this->stringable(11211)->is(['*2*', 'b*'])); + + $this->assertTrue($this->stringable('blah/baz/foo')->is('*/foo')); + + $valueObject = new StringableObjectStub('foo/bar/baz'); + $patternObject = new StringableObjectStub('foo/*'); + + $this->assertTrue($this->stringable($valueObject)->is('foo/bar/baz')); + $this->assertTrue($this->stringable($valueObject)->is($patternObject)); + + // empty patterns + $this->assertFalse($this->stringable('test')->is([])); + } + + public function testKebab() + { + $this->assertSame('laravel-php-framework', (string) $this->stringable('LaravelPhpFramework')->kebab()); + } + + public function testLower() + { + $this->assertSame('foo bar baz', (string) $this->stringable('FOO BAR BAZ')->lower()); + $this->assertSame('foo bar baz', (string) $this->stringable('fOo Bar bAz')->lower()); + } + + public function testUpper() + { + $this->assertSame('FOO BAR BAZ', (string) $this->stringable('foo bar baz')->upper()); + $this->assertSame('FOO BAR BAZ', (string) $this->stringable('foO bAr BaZ')->upper()); + } + + public function testLimit() + { + $this->assertSame('Laravel is...', + (string) $this->stringable('Laravel is a free, open source PHP web application framework.')->limit(10) + ); + $this->assertSame('这是一...', (string) $this->stringable('这是一段中文')->limit(6)); + + $string = 'The PHP framework for web artisans.'; + $this->assertSame('The PHP...', (string) $this->stringable($string)->limit(7)); + $this->assertSame('The PHP', (string) $this->stringable($string)->limit(7, '')); + $this->assertSame('The PHP framework for web artisans.', (string) $this->stringable($string)->limit(100)); + + $nonAsciiString = '这是一段中文'; + $this->assertSame('这是一...', (string) $this->stringable($nonAsciiString)->limit(6)); + $this->assertSame('这是一', (string) $this->stringable($nonAsciiString)->limit(6, '')); + } + + public function testLength() + { + $this->assertSame(11, $this->stringable('foo bar baz')->length()); + $this->assertSame(11, $this->stringable('foo bar baz')->length('UTF-8')); + } + + public function testReplace() + { + $this->assertSame('foo/foo/foo', (string) $this->stringable('?/?/?')->replace('?', 'foo')); + $this->assertSame('bar/bar', (string) $this->stringable('?/?')->replace('?', 'bar')); + $this->assertSame('?/?/?', (string) $this->stringable('? ? ?')->replace(' ', '/')); + $this->assertSame('foo/bar/baz/bam', (string) $this->stringable('?1/?2/?3/?4')->replace(['?1', '?2', '?3', '?4'], ['foo', 'bar', 'baz', 'bam'])); + } + + public function testReplaceArray() + { + $this->assertSame('foo/bar/baz', (string) $this->stringable('?/?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('foo/bar/baz/?', (string) $this->stringable('?/?/?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('?/?/?', (string) $this->stringable('?/?/?')->replaceArray('x', ['foo', 'bar', 'baz'])); + $this->assertSame('foo?/bar/baz', (string) $this->stringable('?/?/?')->replaceArray('?', ['foo?', 'bar', 'baz'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', [1 => 'foo', 2 => 'bar'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', ['x' => 'foo', 'y' => 'bar'])); + } + + public function testReplaceFirst() + { + $this->assertSame('fooqux foobar', (string) $this->stringable('foobar foobar')->replaceFirst('bar', 'qux')); + $this->assertSame('foo/qux? foo/bar?', (string) $this->stringable('foo/bar? foo/bar?')->replaceFirst('bar?', 'qux?')); + $this->assertSame('foo foobar', (string) $this->stringable('foobar foobar')->replaceFirst('bar', '')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceFirst('xxx', 'yyy')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceFirst('', 'yyy')); + // Test for multibyte string support + $this->assertSame('Jxxxnköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceFirst('ö', 'xxx')); + $this->assertSame('Jönköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceFirst('', 'yyy')); + } + + public function testReplaceLast() + { + $this->assertSame('foobar fooqux', (string) $this->stringable('foobar foobar')->replaceLast('bar', 'qux')); + $this->assertSame('foo/bar? foo/qux?', (string) $this->stringable('foo/bar? foo/bar?')->replaceLast('bar?', 'qux?')); + $this->assertSame('foobar foo', (string) $this->stringable('foobar foobar')->replaceLast('bar', '')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceLast('xxx', 'yyy')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceLast('', 'yyy')); + // Test for multibyte string support + $this->assertSame('Malmö Jönkxxxping', (string) $this->stringable('Malmö Jönköping')->replaceLast('ö', 'xxx')); + $this->assertSame('Malmö Jönköping', (string) $this->stringable('Malmö Jönköping')->replaceLast('', 'yyy')); + } + + public function testRemove() + { + $this->assertSame('Fbar', (string) $this->stringable('Foobar')->remove('o')); + $this->assertSame('Foo', (string) $this->stringable('Foobar')->remove('bar')); + $this->assertSame('oobar', (string) $this->stringable('Foobar')->remove('F')); + $this->assertSame('Foobar', (string) $this->stringable('Foobar')->remove('f')); + $this->assertSame('oobar', (string) $this->stringable('Foobar')->remove('f', false)); + + $this->assertSame('Fbr', (string) $this->stringable('Foobar')->remove(['o', 'a'])); + $this->assertSame('Fooar', (string) $this->stringable('Foobar')->remove(['f', 'b'])); + $this->assertSame('ooar', (string) $this->stringable('Foobar')->remove(['f', 'b'], false)); + $this->assertSame('Foobar', (string) $this->stringable('Foo|bar')->remove(['f', '|'])); + } + + public function testReverse() + { + $this->assertSame('FooBar', (string) $this->stringable('raBooF')->reverse()); + $this->assertSame('Teniszütő', (string) $this->stringable('őtüzsineT')->reverse()); + $this->assertSame('❤MultiByte☆', (string) $this->stringable('☆etyBitluM❤')->reverse()); + } + + public function testSnake() + { + $this->assertSame('laravel_p_h_p_framework', (string) $this->stringable('LaravelPHPFramework')->snake()); + $this->assertSame('laravel_php_framework', (string) $this->stringable('LaravelPhpFramework')->snake()); + $this->assertSame('laravel php framework', (string) $this->stringable('LaravelPhpFramework')->snake(' ')); + $this->assertSame('laravel_php_framework', (string) $this->stringable('Laravel Php Framework')->snake()); + $this->assertSame('laravel_php_framework', (string) $this->stringable('Laravel Php Framework ')->snake()); + // ensure cache keys don't overlap + $this->assertSame('laravel__php__framework', (string) $this->stringable('LaravelPhpFramework')->snake('__')); + $this->assertSame('laravel_php_framework_', (string) $this->stringable('LaravelPhpFramework_')->snake('_')); + $this->assertSame('laravel_php_framework', (string) $this->stringable('laravel php Framework')->snake()); + $this->assertSame('laravel_php_frame_work', (string) $this->stringable('laravel php FrameWork')->snake()); + // prevent breaking changes + $this->assertSame('foo-bar', (string) $this->stringable('foo-bar')->snake()); + $this->assertSame('foo-_bar', (string) $this->stringable('Foo-Bar')->snake()); + $this->assertSame('foo__bar', (string) $this->stringable('Foo_Bar')->snake()); + $this->assertSame('żółtałódka', (string) $this->stringable('ŻółtaŁódka')->snake()); + } + + public function testStudly() + { + $this->assertSame('LaravelPHPFramework', (string) $this->stringable('laravel_p_h_p_framework')->studly()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel_php_framework')->studly()); + $this->assertSame('LaravelPhPFramework', (string) $this->stringable('laravel-phP-framework')->studly()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel -_- php -_- framework ')->studly()); + + $this->assertSame('FooBar', (string) $this->stringable('fooBar')->studly()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->studly()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->studly()); // test cache + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-barBaz')->studly()); + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-bar_baz')->studly()); + } + + public function testCamel() + { + $this->assertSame('laravelPHPFramework', (string) $this->stringable('Laravel_p_h_p_framework')->camel()); + $this->assertSame('laravelPhpFramework', (string) $this->stringable('Laravel_php_framework')->camel()); + $this->assertSame('laravelPhPFramework', (string) $this->stringable('Laravel-phP-framework')->camel()); + $this->assertSame('laravelPhpFramework', (string) $this->stringable('Laravel -_- php -_- framework ')->camel()); + + $this->assertSame('fooBar', (string) $this->stringable('FooBar')->camel()); + $this->assertSame('fooBar', (string) $this->stringable('foo_bar')->camel()); + $this->assertSame('fooBar', (string) $this->stringable('foo_bar')->camel()); // test cache + $this->assertSame('fooBarBaz', (string) $this->stringable('Foo-barBaz')->camel()); + $this->assertSame('fooBarBaz', (string) $this->stringable('foo-bar_baz')->camel()); + } + + public function testSubstr() + { + $this->assertSame('Ё', (string) $this->stringable('БГДЖИЛЁ')->substr(-1)); + $this->assertSame('ЛЁ', (string) $this->stringable('БГДЖИЛЁ')->substr(-2)); + $this->assertSame('И', (string) $this->stringable('БГДЖИЛЁ')->substr(-3, 1)); + $this->assertSame('ДЖИЛ', (string) $this->stringable('БГДЖИЛЁ')->substr(2, -1)); + $this->assertSame('', (string) $this->stringable('БГДЖИЛЁ')->substr(4, -4)); + $this->assertSame('ИЛ', (string) $this->stringable('БГДЖИЛЁ')->substr(-3, -1)); + $this->assertSame('ГДЖИЛЁ', (string) $this->stringable('БГДЖИЛЁ')->substr(1)); + $this->assertSame('ГДЖ', (string) $this->stringable('БГДЖИЛЁ')->substr(1, 3)); + $this->assertSame('БГДЖ', (string) $this->stringable('БГДЖИЛЁ')->substr(0, 4)); + $this->assertSame('Ё', (string) $this->stringable('БГДЖИЛЁ')->substr(-1, 1)); + $this->assertSame('', (string) $this->stringable('Б')->substr(2)); + } + + public function testSwap() + { + $this->assertSame('PHP 8 is fantastic', (string) $this->stringable('PHP is awesome')->swap([ + 'PHP' => 'PHP 8', + 'awesome' => 'fantastic', + ])); + } + + public function testSubstrCount() + { + $this->assertSame(3, $this->stringable('laravelPHPFramework')->substrCount('a')); + $this->assertSame(0, $this->stringable('laravelPHPFramework')->substrCount('z')); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('l', 2)); + $this->assertSame(0, $this->stringable('laravelPHPFramework')->substrCount('z', 2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('k', -1)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('k', -1)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', 1, 2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', 1, 2)); + $this->assertSame(3, $this->stringable('laravelPHPFramework')->substrCount('a', 1, -2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', -10, -3)); + } + + public function testSubstrReplace() + { + $this->assertSame('12:00', (string) $this->stringable('1200')->substrReplace(':', 2, 0)); + $this->assertSame('The Laravel Framework', (string) $this->stringable('The Framework')->substrReplace('Laravel ', 4, 0)); + $this->assertSame('Laravel – The PHP Framework for Web Artisans', (string) $this->stringable('Laravel Framework')->substrReplace('– The PHP Framework for Web Artisans', 8)); + } + + public function testPadBoth() + { + $this->assertSame('__Alien___', (string) $this->stringable('Alien')->padBoth(10, '_')); + $this->assertSame(' Alien ', (string) $this->stringable('Alien')->padBoth(10)); + $this->assertSame(' ❤MultiByte☆ ', (string) $this->stringable('❤MultiByte☆')->padBoth(16)); + } + + public function testPadLeft() + { + $this->assertSame('-=-=-Alien', (string) $this->stringable('Alien')->padLeft(10, '-=')); + $this->assertSame(' Alien', (string) $this->stringable('Alien')->padLeft(10)); + $this->assertSame(' ❤MultiByte☆', (string) $this->stringable('❤MultiByte☆')->padLeft(16)); + } + + public function testPadRight() + { + $this->assertSame('Alien-----', (string) $this->stringable('Alien')->padRight(10, '-')); + $this->assertSame('Alien ', (string) $this->stringable('Alien')->padRight(10)); + $this->assertSame('❤MultiByte☆ ', (string) $this->stringable('❤MultiByte☆')->padRight(16)); + } + + public function testChunk() + { + $chunks = $this->stringable('foobarbaz')->split(3); + + $this->assertInstanceOf(Collection::class, $chunks); + $this->assertSame(['foo', 'bar', 'baz'], $chunks->all()); + } + + public function testJsonSerialize() + { + $this->assertSame('"foo"', json_encode($this->stringable('foo'))); + } + + public function testTap() + { + $stringable = $this->stringable('foobarbaz'); + + $fromTheTap = ''; + + $stringable = $stringable->tap(function (Stringable $string) use (&$fromTheTap) { + $fromTheTap = $string->substr(0, 3); + }); + + $this->assertSame('foo', (string) $fromTheTap); + $this->assertSame('foobarbaz', (string) $stringable); + } + + public function testPipe() + { + $callback = function ($stringable) { + return 'bar'; + }; + + $this->assertInstanceOf(Stringable::class, $this->stringable('foo')->pipe($callback)); + $this->assertSame('bar', (string) $this->stringable('foo')->pipe($callback)); + } + + public function testMarkdown() + { + $this->assertEquals("<p><em>hello world</em></p>\n", $this->stringable('*hello world*')->markdown()); + $this->assertEquals("<h1>hello world</h1>\n", $this->stringable('# hello world')->markdown()); + } + + public function testMask() + { + $this->assertEquals('tay*************', $this->stringable('taylor@email.com')->mask('*', 3)); + $this->assertEquals('******@email.com', $this->stringable('taylor@email.com')->mask('*', 0, 6)); + $this->assertEquals('tay*************', $this->stringable('taylor@email.com')->mask('*', -13)); + $this->assertEquals('tay***@email.com', $this->stringable('taylor@email.com')->mask('*', -13, 3)); + + $this->assertEquals('****************', $this->stringable('taylor@email.com')->mask('*', -17)); + $this->assertEquals('*****r@email.com', $this->stringable('taylor@email.com')->mask('*', -99, 5)); + + $this->assertEquals('taylor@email.com', $this->stringable('taylor@email.com')->mask('*', 16)); + $this->assertEquals('taylor@email.com', $this->stringable('taylor@email.com')->mask('*', 16, 99)); + + $this->assertEquals('taylor@email.com', $this->stringable('taylor@email.com')->mask('', 3)); + + $this->assertEquals('taysssssssssssss', $this->stringable('taylor@email.com')->mask('something', 3)); + + $this->assertEquals('这是一***', $this->stringable('这是一段中文')->mask('*', 3)); + $this->assertEquals('**一段中文', $this->stringable('这是一段中文')->mask('*', 0, 2)); + } + + public function testRepeat() + { + $this->assertSame('aaaaa', (string) $this->stringable('a')->repeat(5)); + $this->assertSame('', (string) $this->stringable('')->repeat(5)); + } + + public function testWordCount() + { + $this->assertEquals(2, $this->stringable('Hello, world!')->wordCount()); + $this->assertEquals(10, $this->stringable('Hi, this is my first contribution to the Laravel framework.')->wordCount()); + } + + public function testToHtmlString() + { + $this->assertEquals( + new HtmlString('<h1>Test String</h1>'), + $this->stringable('<h1>Test String</h1>')->toHtmlString() + ); + } + + public function testStripTags() + { + $this->assertSame('beforeafter', (string) $this->stringable('before<br>after')->stripTags()); + $this->assertSame('before<br>after', (string) $this->stringable('before<br>after')->stripTags('<br>')); + $this->assertSame('before<br>after', (string) $this->stringable('<strong>before</strong><br>after')->stripTags('<br>')); + $this->assertSame('<strong>before</strong><br>after', (string) $this->stringable('<strong>before</strong><br>after')->stripTags('<br><strong>')); + } + + public function testScan() + { + $this->assertSame([123456], $this->stringable('SN/123456')->scan('SN/%d')->toArray()); + $this->assertSame(['Otwell', 'Taylor'], $this->stringable('Otwell, Taylor')->scan('%[^,],%s')->toArray()); + $this->assertSame(['filename', 'jpg'], $this->stringable('filename.jpg')->scan('%[^.].%s')->toArray()); + } +} diff --git a/tests/Support/SupportTappableTest.php b/tests/Support/SupportTappableTest.php index be8c152d2173720cea2e978df8cdf1c491977462..10ec6e58be656521051b67cdb9607d9c666d8872 100644 --- a/tests/Support/SupportTappableTest.php +++ b/tests/Support/SupportTappableTest.php @@ -16,6 +16,34 @@ class SupportTappableTest extends TestCase $this->assertSame('MyName', $name); } + public function testTappableClassWithInvokableClass() + { + $name = TappableClass::make()->tap(new class + { + public function __invoke($tappable) + { + $tappable->setName('MyName'); + } + })->getName(); + + $this->assertSame('MyName', $name); + } + + public function testTappableClassWithNoneInvokableClass() + { + $this->expectException('Error'); + + $name = TappableClass::make()->tap(new class + { + public function setName($tappable) + { + $tappable->setName('MyName'); + } + })->getName(); + + $this->assertSame('MyName', $name); + } + public function testTappableClassWithoutCallback() { $name = TappableClass::make()->tap()->setName('MyName')->getName(); diff --git a/tests/Support/SupportTestingBusFakeTest.php b/tests/Support/SupportTestingBusFakeTest.php index 91319ee555327c2c75242a65d0d7bc7d89db4f54..bb04963040aaaf67d2bfab8fb93ca16c945a7e30 100644 --- a/tests/Support/SupportTestingBusFakeTest.php +++ b/tests/Support/SupportTestingBusFakeTest.php @@ -2,7 +2,7 @@ namespace Illuminate\Tests\Support; -use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Bus\QueueingDispatcher; use Illuminate\Support\Testing\Fakes\BusFake; use Mockery as m; use PHPUnit\Framework\Constraint\ExceptionMessage; @@ -17,7 +17,7 @@ class SupportTestingBusFakeTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->fake = new BusFake(m::mock(Dispatcher::class)); + $this->fake = new BusFake(m::mock(QueueingDispatcher::class)); } protected function tearDown(): void @@ -40,13 +40,22 @@ class SupportTestingBusFakeTest extends TestCase $this->fake->assertDispatched(BusJobStub::class); } + public function testAssertDispatchedWithClosure() + { + $this->fake->dispatch(new BusJobStub); + + $this->fake->assertDispatched(function (BusJobStub $job) { + return true; + }); + } + public function testAssertDispatchedAfterResponse() { try { $this->fake->assertDispatchedAfterResponse(BusJobStub::class); $this->fail(); } catch (ExpectationFailedException $e) { - $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched for after sending the response.')); + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched after sending the response.')); } $this->fake->dispatchAfterResponse(new BusJobStub); @@ -54,6 +63,53 @@ class SupportTestingBusFakeTest extends TestCase $this->fake->assertDispatchedAfterResponse(BusJobStub::class); } + public function testAssertDispatchedAfterResponseClosure() + { + try { + $this->fake->assertDispatchedAfterResponse(function (BusJobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched after sending the response.')); + } + } + + public function testAssertDispatchedSync() + { + try { + $this->fake->assertDispatchedSync(BusJobStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched synchronously.')); + } + + $this->fake->dispatch(new BusJobStub); + + try { + $this->fake->assertDispatchedSync(BusJobStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched synchronously.')); + } + + $this->fake->dispatchSync(new BusJobStub); + + $this->fake->assertDispatchedSync(BusJobStub::class); + } + + public function testAssertDispatchedSyncClosure() + { + try { + $this->fake->assertDispatchedSync(function (BusJobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was not dispatched synchronously.')); + } + } + public function testAssertDispatchedNow() { $this->fake->dispatchNow(new BusJobStub); @@ -91,6 +147,21 @@ class SupportTestingBusFakeTest extends TestCase $this->fake->assertDispatchedAfterResponse(BusJobStub::class, 2); } + public function testAssertDispatchedSyncWithCallbackInt() + { + $this->fake->dispatchSync(new BusJobStub); + $this->fake->dispatchSync(new BusJobStub); + + try { + $this->fake->assertDispatchedSync(BusJobStub::class, 1); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was synchronously pushed 2 times instead of 1 times.')); + } + + $this->fake->assertDispatchedSync(BusJobStub::class, 2); + } + public function testAssertDispatchedWithCallbackFunction() { $this->fake->dispatch(new OtherBusJobStub); @@ -125,7 +196,7 @@ class SupportTestingBusFakeTest extends TestCase }); $this->fail(); } catch (ExpectationFailedException $e) { - $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\OtherBusJobStub] job was not dispatched for after sending the response.')); + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\OtherBusJobStub] job was not dispatched after sending the response.')); } $this->fake->assertDispatchedAfterResponse(OtherBusJobStub::class, function ($job) { @@ -137,6 +208,29 @@ class SupportTestingBusFakeTest extends TestCase }); } + public function testAssertDispatchedSyncWithCallbackFunction() + { + $this->fake->dispatchSync(new OtherBusJobStub); + $this->fake->dispatchSync(new OtherBusJobStub(1)); + + try { + $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) { + return $job->id === 0; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\OtherBusJobStub] job was not dispatched synchronously.')); + } + + $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) { + return $job->id === null; + }); + + $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) { + return $job->id === 1; + }); + } + public function testAssertDispatchedTimes() { $this->fake->dispatch(new BusJobStub); @@ -167,6 +261,21 @@ class SupportTestingBusFakeTest extends TestCase $this->fake->assertDispatchedAfterResponseTimes(BusJobStub::class, 2); } + public function testAssertDispatchedSyncTimes() + { + $this->fake->dispatchSync(new BusJobStub); + $this->fake->dispatchSync(new BusJobStub); + + try { + $this->fake->assertDispatchedSyncTimes(BusJobStub::class, 1); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\BusJobStub] job was synchronously pushed 2 times instead of 1 times.')); + } + + $this->fake->assertDispatchedSyncTimes(BusJobStub::class, 2); + } + public function testAssertNotDispatched() { $this->fake->assertNotDispatched(BusJobStub::class); @@ -182,6 +291,21 @@ class SupportTestingBusFakeTest extends TestCase } } + public function testAssertNotDispatchedWithClosure() + { + $this->fake->dispatch(new BusJobStub); + $this->fake->dispatchNow(new BusJobStub); + + try { + $this->fake->assertNotDispatched(function (BusJobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched.')); + } + } + public function testAssertNotDispatchedAfterResponse() { $this->fake->assertNotDispatchedAfterResponse(BusJobStub::class); @@ -192,13 +316,69 @@ class SupportTestingBusFakeTest extends TestCase $this->fake->assertNotDispatchedAfterResponse(BusJobStub::class); $this->fail(); } catch (ExpectationFailedException $e) { - $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched for after sending the response.')); + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched after sending the response.')); + } + } + + public function testAssertNotDispatchedAfterResponseClosure() + { + $this->fake->dispatchAfterResponse(new BusJobStub); + + try { + $this->fake->assertNotDispatchedAfterResponse(function (BusJobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched after sending the response.')); + } + } + + public function testAssertNotDispatchedSync() + { + $this->fake->assertNotDispatchedSync(BusJobStub::class); + + $this->fake->dispatchSync(new BusJobStub); + + try { + $this->fake->assertNotDispatchedSync(BusJobStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched synchronously.')); + } + } + + public function testAssertNotDispatchedSyncClosure() + { + $this->fake->dispatchSync(new BusJobStub); + + try { + $this->fake->assertNotDispatchedSync(function (BusJobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\BusJobStub] job was dispatched synchronously.')); + } + } + + public function testAssertNothingDispatched() + { + $this->fake->assertNothingDispatched(); + + $this->fake->dispatch(new BusJobStub); + + try { + $this->fake->assertNothingDispatched(); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('Jobs were dispatched unexpectedly.')); } } public function testAssertDispatchedWithIgnoreClass() { - $dispatcher = m::mock(Dispatcher::class); + $dispatcher = m::mock(QueueingDispatcher::class); $job = new BusJobStub; $dispatcher->shouldReceive('dispatch')->once()->with($job); @@ -222,7 +402,7 @@ class SupportTestingBusFakeTest extends TestCase public function testAssertDispatchedWithIgnoreCallback() { - $dispatcher = m::mock(Dispatcher::class); + $dispatcher = m::mock(QueueingDispatcher::class); $job = new BusJobStub; $dispatcher->shouldReceive('dispatch')->once()->with($job); diff --git a/tests/Support/SupportTestingEventFakeTest.php b/tests/Support/SupportTestingEventFakeTest.php index 40b3878c10208520496a7ba864998e7026ae72ad..d51562d10c586ff815c9a4984e1b0a7dafbe4b7d 100644 --- a/tests/Support/SupportTestingEventFakeTest.php +++ b/tests/Support/SupportTestingEventFakeTest.php @@ -31,6 +31,15 @@ class SupportTestingEventFakeTest extends TestCase $this->fake->assertDispatched(EventStub::class); } + public function testAssertDispatchedWithClosure() + { + $this->fake->dispatch(new EventStub); + + $this->fake->assertDispatched(function (EventStub $event) { + return true; + }); + } + public function testAssertDispatchedWithCallbackInt() { $this->fake->dispatch(EventStub::class); @@ -75,6 +84,20 @@ class SupportTestingEventFakeTest extends TestCase } } + public function testAssertNotDispatchedWithClosure() + { + $this->fake->dispatch(new EventStub); + + try { + $this->fake->assertNotDispatched(function (EventStub $event) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\EventStub] event was dispatched.')); + } + } + public function testAssertDispatchedWithIgnore() { $dispatcher = m::mock(Dispatcher::class); @@ -95,6 +118,21 @@ class SupportTestingEventFakeTest extends TestCase $fake->assertDispatched('Bar'); $fake->assertNotDispatched('Baz'); } + + public function testAssertNothingDispatched() + { + $this->fake->assertNothingDispatched(); + + $this->fake->dispatch(EventStub::class); + $this->fake->dispatch(EventStub::class); + + try { + $this->fake->assertNothingDispatched(); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('2 unexpected events were dispatched.')); + } + } } class EventStub diff --git a/tests/Support/SupportTestingMailFakeTest.php b/tests/Support/SupportTestingMailFakeTest.php index f6b18c18d9ca9964c41415b5b49b6c25824fdf4d..78ec3e926086e35beb2c7e027d67b84ecd55b247 100644 --- a/tests/Support/SupportTestingMailFakeTest.php +++ b/tests/Support/SupportTestingMailFakeTest.php @@ -14,12 +14,12 @@ use PHPUnit\Framework\TestCase; class SupportTestingMailFakeTest extends TestCase { /** - * @var MailFake + * @var \Illuminate\Support\Testing\Fakes\MailFake */ private $fake; /** - * @var MailableStub + * @var \Illuminate\Tests\Support\MailableStub */ private $mailable; @@ -69,6 +69,22 @@ class SupportTestingMailFakeTest extends TestCase } } + public function testAssertNotSentWithClosure() + { + $callback = function (MailableStub $mail) { + return $mail->hasTo('taylor@laravel.com'); + }; + + $this->fake->assertNotSent($callback); + + $this->fake->to('taylor@laravel.com')->send($this->mailable); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessageMatches('/The unexpected \['.preg_quote(MailableStub::class, '/').'\] mailable was sent./m'); + + $this->fake->assertNotSent($callback); + } + public function testAssertSentTimes() { $this->fake->to('taylor@laravel.com')->send($this->mailable); @@ -154,6 +170,24 @@ class SupportTestingMailFakeTest extends TestCase $this->assertThat($e, new ExceptionMessage('The following mailables were queued unexpectedly: Illuminate\Tests\Support\MailableStub')); } } + + public function testAssertQueuedWithClosure() + { + $this->fake->to($user = new LocalizedRecipientStub)->queue($this->mailable); + + $this->fake->assertQueued(function (MailableStub $mail) use ($user) { + return $mail->hasTo($user); + }); + } + + public function testAssertSentWithClosure() + { + $this->fake->to($user = new LocalizedRecipientStub)->send($this->mailable); + + $this->fake->assertSent(function (MailableStub $mail) use ($user) { + return $mail->hasTo($user); + }); + } } class MailableStub extends Mailable implements MailableContract diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php index 704c7b0c9f567dadec23cc4bfe5d5d524d433d67..06f49b6a16552ba3cf3a42c017712ab558d3a4bf 100644 --- a/tests/Support/SupportTestingNotificationFakeTest.php +++ b/tests/Support/SupportTestingNotificationFakeTest.php @@ -5,6 +5,7 @@ namespace Illuminate\Tests\Support; use Exception; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Foundation\Auth\User; +use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Notifications\Notification; use Illuminate\Support\Collection; use Illuminate\Support\Testing\Fakes\NotificationFake; @@ -15,23 +16,24 @@ use PHPUnit\Framework\TestCase; class SupportTestingNotificationFakeTest extends TestCase { /** - * @var NotificationFake + * @var \Illuminate\Support\Testing\Fakes\NotificationFake */ private $fake; /** - * @var NotificationStub + * @var \Illuminate\Tests\Support\NotificationStub */ private $notification; /** - * @var UserStub + * @var \Illuminate\Tests\Support\UserStub */ private $user; protected function setUp(): void { parent::setUp(); + $this->fake = new NotificationFake; $this->notification = new NotificationStub; $this->user = new UserStub; @@ -51,6 +53,31 @@ class SupportTestingNotificationFakeTest extends TestCase $this->fake->assertSentTo($this->user, NotificationStub::class); } + public function testAssertSentToClosure() + { + $this->fake->send($this->user, new NotificationStub); + + $this->fake->assertSentTo($this->user, function (NotificationStub $notification) { + return true; + }); + } + + public function testAssertSentOnDemand() + { + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemand(NotificationStub::class); + } + + public function testAssertSentOnDemandClosure() + { + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemand(NotificationStub::class, function (NotificationStub $notification) { + return true; + }); + } + public function testAssertNotSentTo() { $this->fake->assertNotSentTo($this->user, NotificationStub::class); @@ -65,6 +92,20 @@ class SupportTestingNotificationFakeTest extends TestCase } } + public function testAssertNotSentToClosure() + { + $this->fake->send($this->user, new NotificationStub); + + try { + $this->fake->assertNotSentTo($this->user, function (NotificationStub $notification) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\NotificationStub] notification was sent.')); + } + } + public function testAssertSentToFailsForEmptyArray() { $this->expectException(Exception::class); @@ -110,6 +151,32 @@ class SupportTestingNotificationFakeTest extends TestCase $this->fake->assertTimesSent(3, NotificationStub::class); } + public function testAssertSentToTimes() + { + $this->fake->assertSentToTimes($this->user, NotificationStub::class, 0); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->assertSentToTimes($this->user, NotificationStub::class, 3); + } + + public function testAssertSentOnDemandTimes() + { + $this->fake->assertSentOnDemandTimes(NotificationStub::class, 0); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemandTimes(NotificationStub::class, 3); + } + public function testAssertSentToWhenNotifiableHasPreferredLocale() { $user = new LocalizedUserStub; @@ -120,6 +187,15 @@ class SupportTestingNotificationFakeTest extends TestCase return $notifiable === $user && $locale === 'au'; }); } + + public function testAssertSentToWhenNotifiableHasFalsyShouldSend() + { + $user = new LocalizedUserStub; + + $this->fake->send($user, new NotificationWithFalsyShouldSendStub); + + $this->fake->assertNotSentTo($user, NotificationWithFalsyShouldSendStub::class); + } } class NotificationStub extends Notification @@ -130,6 +206,19 @@ class NotificationStub extends Notification } } +class NotificationWithFalsyShouldSendStub extends Notification +{ + public function via($notifiable) + { + return ['mail']; + } + + public function shouldSend($notifiable, $channel) + { + return false; + } +} + class UserStub extends User { // diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php index eec942a641fe1f5ea5fd47213a2f530e3643e517..cf22717cf29b503850cb0eafe3b4e1ce307aa047 100644 --- a/tests/Support/SupportTestingQueueFakeTest.php +++ b/tests/Support/SupportTestingQueueFakeTest.php @@ -13,12 +13,12 @@ use PHPUnit\Framework\TestCase; class SupportTestingQueueFakeTest extends TestCase { /** - * @var QueueFake + * @var \Illuminate\Support\Testing\Fakes\QueueFake */ private $fake; /** - * @var JobStub + * @var \Illuminate\Tests\Support\JobStub */ private $job; @@ -43,6 +43,15 @@ class SupportTestingQueueFakeTest extends TestCase $this->fake->assertPushed(JobStub::class); } + public function testAssertPushedWithClosure() + { + $this->fake->push($this->job); + + $this->fake->assertPushed(function (JobStub $job) { + return true; + }); + } + public function testQueueSize() { $this->assertEquals(0, $this->fake->size()); @@ -53,13 +62,27 @@ class SupportTestingQueueFakeTest extends TestCase } public function testAssertNotPushed() + { + $this->fake->push($this->job); + + try { + $this->fake->assertNotPushed(JobStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\JobStub] job was pushed.')); + } + } + + public function testAssertNotPushedWithClosure() { $this->fake->assertNotPushed(JobStub::class); $this->fake->push($this->job); try { - $this->fake->assertNotPushed(JobStub::class); + $this->fake->assertNotPushed(function (JobStub $job) { + return true; + }); $this->fail(); } catch (ExpectationFailedException $e) { $this->assertThat($e, new ExceptionMessage('The unexpected [Illuminate\Tests\Support\JobStub] job was pushed.')); @@ -80,6 +103,24 @@ class SupportTestingQueueFakeTest extends TestCase $this->fake->assertPushedOn('foo', JobStub::class); } + public function testAssertPushedOnWithClosure() + { + $this->fake->push($this->job, '', 'foo'); + + try { + $this->fake->assertPushedOn('bar', function (JobStub $job) { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertThat($e, new ExceptionMessage('The expected [Illuminate\Tests\Support\JobStub] job was not pushed.')); + } + + $this->fake->assertPushedOn('foo', function (JobStub $job) { + return true; + }); + } + public function testAssertPushedTimes() { $this->fake->push($this->job); @@ -179,7 +220,7 @@ class SupportTestingQueueFakeTest extends TestCase $this->fake->assertPushedWithChain(JobWithChainAndParameterStub::class, [ JobStub::class, ], function ($job) { - return $job->parameter == 'second'; + return $job->parameter === 'second'; }); try { @@ -187,7 +228,7 @@ class SupportTestingQueueFakeTest extends TestCase JobStub::class, JobStub::class, ], function ($job) { - return $job->parameter == 'second'; + return $job->parameter === 'second'; }); $this->fail(); } catch (ExpectationFailedException $e) { diff --git a/tests/Support/SupportTimeboxTest.php b/tests/Support/SupportTimeboxTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c34af457129785e5fbec1bcbf806b39bfe6ab64d --- /dev/null +++ b/tests/Support/SupportTimeboxTest.php @@ -0,0 +1,53 @@ +<?php + +namespace Illuminate\Tests\Support; + +use Illuminate\Support\Timebox; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class SupportTimeboxTest extends TestCase +{ + public function testMakeExecutesCallback() + { + $callback = function () { + $this->assertTrue(true); + }; + + (new Timebox)->call($callback, 0); + } + + public function testMakeWaitsForMicroseconds() + { + $mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial(); + $mock->shouldReceive('usleep')->once(); + + $mock->call(function () { + }, 10000); + + $mock->shouldHaveReceived('usleep')->once(); + } + + public function testMakeShouldNotSleepWhenEarlyReturnHasBeenFlagged() + { + $mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial(); + $mock->call(function ($timebox) { + $timebox->returnEarly(); + }, 10000); + + $mock->shouldNotHaveReceived('usleep'); + } + + public function testMakeShouldSleepWhenDontEarlyReturnHasBeenFlagged() + { + $mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial(); + $mock->shouldReceive('usleep')->once(); + + $mock->call(function ($timebox) { + $timebox->returnEarly(); + $timebox->dontReturnEarly(); + }, 10000); + + $mock->shouldHaveReceived('usleep')->once(); + } +} diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7b00d39a2413a81381775556851ec8f60f6ccd0e --- /dev/null +++ b/tests/Support/ValidatedInputTest.php @@ -0,0 +1,33 @@ +<?php + +namespace Illuminate\Tests\Support; + +use Illuminate\Support\ValidatedInput; +use PHPUnit\Framework\TestCase; + +class ValidatedInputTest extends TestCase +{ + public function test_can_access_input() + { + $input = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); + + $this->assertEquals('Taylor', $input->name); + $this->assertEquals('Taylor', $input['name']); + $this->assertEquals(['name' => 'Taylor'], $input->only(['name'])); + $this->assertEquals(['name' => 'Taylor'], $input->except(['votes'])); + $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); + } + + public function test_can_merge_items() + { + $input = new ValidatedInput(['name' => 'Taylor']); + + $input = $input->merge(['votes' => 100]); + + $this->assertEquals('Taylor', $input->name); + $this->assertEquals('Taylor', $input['name']); + $this->assertEquals(['name' => 'Taylor'], $input->only(['name'])); + $this->assertEquals(['name' => 'Taylor'], $input->except(['votes'])); + $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); + } +} diff --git a/tests/Testing/AssertRedirectToSignedRouteTest.php b/tests/Testing/AssertRedirectToSignedRouteTest.php new file mode 100644 index 0000000000000000000000000000000000000000..601f269d931cbd93b17c0b71eac4127eb5127cf9 --- /dev/null +++ b/tests/Testing/AssertRedirectToSignedRouteTest.php @@ -0,0 +1,99 @@ +<?php + +namespace Illuminate\Tests\Testing; + +use Illuminate\Contracts\Routing\Registrar; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\UrlGenerator; +use Illuminate\Support\Facades\Facade; +use Orchestra\Testbench\TestCase; + +class AssertRedirectToSignedRouteTest extends TestCase +{ + /** + * @var \Illuminate\Contracts\Routing\Registrar + */ + private $router; + + /** + * @var \Illuminate\Routing\UrlGenerator + */ + private $urlGenerator; + + protected function setUp(): void + { + parent::setUp(); + + $this->router = $this->app->make(Registrar::class); + + $this->router + ->get('signed-route') + ->name('signed-route'); + + $this->router + ->get('signed-route-with-param/{param}') + ->name('signed-route-with-param'); + + $this->urlGenerator = $this->app->make(UrlGenerator::class); + } + + public function testAssertRedirectToSignedRouteWithoutRouteName() + { + $this->router->get('test-route', function () { + return new RedirectResponse($this->urlGenerator->signedRoute('signed-route')); + }); + + $this->get('test-route') + ->assertRedirectToSignedRoute(); + } + + public function testAssertRedirectToSignedRouteWithRouteName() + { + $this->router->get('test-route', function () { + return new RedirectResponse($this->urlGenerator->signedRoute('signed-route')); + }); + + $this->get('test-route') + ->assertRedirectToSignedRoute('signed-route'); + } + + public function testAssertRedirectToSignedRouteWithRouteNameAndParams() + { + $this->router->get('test-route', function () { + return new RedirectResponse($this->urlGenerator->signedRoute('signed-route-with-param', 'hello')); + }); + + $this->router->get('test-route-with-extra-param', function () { + return new RedirectResponse($this->urlGenerator->signedRoute('signed-route-with-param', [ + 'param' => 'foo', + 'extra' => 'another', + ])); + }); + + $this->get('test-route') + ->assertRedirectToSignedRoute('signed-route-with-param', 'hello'); + + $this->get('test-route-with-extra-param') + ->assertRedirectToSignedRoute('signed-route-with-param', [ + 'param' => 'foo', + 'extra' => 'another', + ]); + } + + public function testAssertRedirectToSignedRouteWithRouteNameToTemporarySignedRoute() + { + $this->router->get('test-route', function () { + return new RedirectResponse($this->urlGenerator->temporarySignedRoute('signed-route', 60)); + }); + + $this->get('test-route') + ->assertRedirectToSignedRoute('signed-route'); + } + + public function tearDown(): void + { + parent::tearDown(); + + Facade::setFacadeApplication(null); + } +} diff --git a/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php b/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f5d711ca860526372a99544371a9f741fe3f656b --- /dev/null +++ b/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php @@ -0,0 +1,54 @@ +<?php + +namespace Illuminate\Tests\Testing\Concerns; + +use ErrorException; +use Illuminate\Foundation\Testing\Concerns\InteractsWithDeprecationHandling; +use PHPUnit\Framework\TestCase; + +class InteractsWithDeprecationHandlingTest extends TestCase +{ + use InteractsWithDeprecationHandling; + + protected $original; + + protected $deprecationsFound = false; + + public function setUp(): void + { + parent::setUp(); + + $this->original = set_error_handler(function () { + $this->deprecationsFound = true; + }); + } + + public function testWithDeprecationHandling() + { + $this->withDeprecationHandling(); + + trigger_error('Something is deprecated', E_USER_DEPRECATED); + + $this->assertTrue($this->deprecationsFound); + } + + public function testWithoutDeprecationHandling() + { + $this->withoutDeprecationHandling(); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Something is deprecated'); + + trigger_error('Something is deprecated', E_USER_DEPRECATED); + } + + public function tearDown(): void + { + set_error_handler($this->original); + + $this->originalDeprecationHandler = null; + $this->deprecationsFound = false; + + parent::tearDown(); + } +} diff --git a/tests/Testing/Concerns/TestDatabasesTest.php b/tests/Testing/Concerns/TestDatabasesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9b303c13a560e62583c5df3af7dfbea8768bed44 --- /dev/null +++ b/tests/Testing/Concerns/TestDatabasesTest.php @@ -0,0 +1,112 @@ +<?php + +namespace Illuminate\Tests\Testing\Concerns; + +use Illuminate\Config\Repository as Config; +use Illuminate\Container\Container; +use Illuminate\Support\Facades\DB; +use Illuminate\Testing\Concerns\TestDatabases; +use Mockery as m; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +class TestDatabasesTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Container::setInstance($container = new Container); + + $container->singleton('config', function () { + return m::mock(Config::class) + ->shouldReceive('get') + ->once() + ->with('database.default', null) + ->andReturn('mysql') + ->getMock(); + }); + + $_SERVER['LARAVEL_PARALLEL_TESTING'] = 1; + } + + public function testSwitchToDatabaseWithoutUrl() + { + DB::shouldReceive('purge')->once(); + + config()->shouldReceive('get') + ->once() + ->with('database.connections.mysql.url', false) + ->andReturn(false); + + config()->shouldReceive('set') + ->once() + ->with('database.connections.mysql.database', 'my_database_test_1'); + + $this->switchToDatabase('my_database_test_1'); + } + + /** + * @dataProvider databaseUrls + */ + public function testSwitchToDatabaseWithUrl($testDatabase, $url, $testUrl) + { + DB::shouldReceive('purge')->once(); + + config()->shouldReceive('get') + ->once() + ->with('database.connections.mysql.url', false) + ->andReturn($url); + + config()->shouldReceive('set') + ->once() + ->with('database.connections.mysql.url', $testUrl); + + $this->switchToDatabase($testDatabase); + } + + public function switchToDatabase($database) + { + $instance = new class + { + use TestDatabases; + }; + + $method = new ReflectionMethod($instance, 'switchToDatabase'); + tap($method)->setAccessible(true)->invoke($instance, $database); + } + + public function databaseUrls() + { + return [ + [ + 'my_database_test_1', + 'mysql://root:@127.0.0.1/my_database?charset=utf8mb4', + 'mysql://root:@127.0.0.1/my_database_test_1?charset=utf8mb4', + ], + [ + 'my_database_test_1', + 'mysql://my-user:@localhost/my_database', + 'mysql://my-user:@localhost/my_database_test_1', + ], + [ + 'my-database_test_1', + 'postgresql://my_database_user:@127.0.0.1/my-database?charset=utf8', + 'postgresql://my_database_user:@127.0.0.1/my-database_test_1?charset=utf8', + ], + ]; + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + DB::clearResolvedInstances(); + DB::setFacadeApplication(null); + + unset($_SERVER['LARAVEL_PARALLEL_TESTING']); + + m::close(); + } +} diff --git a/tests/Testing/Fluent/AssertTest.php b/tests/Testing/Fluent/AssertTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8397603a71820ccac1d73cf3e5c92a8f1c465d05 --- /dev/null +++ b/tests/Testing/Fluent/AssertTest.php @@ -0,0 +1,1291 @@ +<?php + +namespace Illuminate\Tests\Testing\Fluent; + +use Illuminate\Support\Collection; +use Illuminate\Testing\Fluent\AssertableJson; +use Illuminate\Tests\Testing\Stubs\ArrayableStubObject; +use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use TypeError; + +class AssertTest extends TestCase +{ + public function testAssertHas() + { + $assert = AssertableJson::fromArray([ + 'prop' => 'value', + ]); + + $assert->has('prop'); + } + + public function testAssertHasFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [prop] does not exist.'); + + $assert->has('prop'); + } + + public function testAssertHasNestedProp() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->has('example.nested'); + } + + public function testAssertHasFailsWhenNestedPropMissing() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.another] does not exist.'); + + $assert->has('example.another'); + } + + public function testAssertHasCountItemsInProp() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', 2); + } + + public function testAssertHasCountFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1); + } + + public function testAssertHasCountFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', 1); + } + + public function testAssertHasFailsWhenSecondArgumentUnsupportedType() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(TypeError::class); + + $assert->has('bar', 'invalid'); + } + + public function testAssertHasOnlyCounts() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $assert->has(3); + } + + public function testAssertHasOnlyCountFails() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Root level does not have the expected size.'); + + $assert->has(2); + } + + public function testAssertHasOnlyCountFailsScoped() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', function ($bar) { + $bar->has(3); + }); + } + + public function testAssertCount() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $assert->count(3); + } + + public function testAssertCountFails() + { + $assert = AssertableJson::fromArray([ + 'foo', + 'bar', + 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Root level does not have the expected size.'); + + $assert->count(2); + } + + public function testAssertCountFailsScoped() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', function ($bar) { + $bar->count(3); + }); + } + + public function testAssertMissing() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => true, + ], + ]); + + $assert->missing('foo.baz'); + } + + public function testAssertMissingFailsWhenPropExists() + { + $assert = AssertableJson::fromArray([ + 'prop' => 'value', + 'foo' => [ + 'bar' => true, + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.bar] was found while it was expected to be missing.'); + + $assert->missing('foo.bar'); + } + + public function testAssertMissingAll() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll([ + 'foo', + 'bar', + ]); + } + + public function testAssertMissingAllFailsWhenAtLeastOnePropExists() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll([ + 'bar', + 'baz', + ]); + } + + public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll('foo', 'bar'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll('bar', 'baz'); + } + + public function testAssertWhereMatchesValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $assert->where('bar', 'value'); + } + + public function testAssertWhereFailsWhenDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', 'invalid'); + } + + public function testAssertWhereFailsWhenMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->where('baz', 'invalid'); + } + + public function testAssertWhereFailsWhenMachingLoosely() + { + $assert = AssertableJson::fromArray([ + 'bar' => 1, + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', true); + } + + public function testAssertWhereUsingClosure() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $assert->where('bar', function ($value) { + return $value === 'baz'; + }); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] was marked as invalid using a closure.'); + + $assert->where('bar', function ($value) { + return $value === 'invalid'; + }); + } + + public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollections() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'foo', + 'example' => 'value', + ], + ]); + + $assert->where('bar', function ($value) { + $this->assertInstanceOf(Collection::class, $value); + + return $value->count() === 2; + }); + } + + public function testAssertWhereMatchesValueUsingArrayable() + { + $stub = ArrayableStubObject::make(['foo' => 'bar']); + + $assert = AssertableJson::fromArray([ + 'bar' => $stub->toArray(), + ]); + + $assert->where('bar', $stub); + } + + public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently() + { + $assert = AssertableJson::fromArray([ + 'data' => [ + 'status' => 200, + 'user' => [ + 'id' => 1, + 'name' => 'Taylor', + ], + ], + ]); + + $assert->where('data', [ + 'user' => [ + 'name' => 'Taylor', + 'id' => 1, + ], + 'status' => 200, + ]); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable() + { + $assert = AssertableJson::fromArray([ + 'bar' => ['id' => 1, 'name' => 'Example'], + 'baz' => [ + 'id' => 1, + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + ], + ]); + + $assert + ->where('bar', ArrayableStubObject::make(['name' => 'Example', 'id' => 1])) + ->where('baz', [ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'id' => 1, + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + ]); + } + + public function testAssertWhereContainsFailsWithEmptyValue() + { + $assert = AssertableJson::fromArray([]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain [1].'); + + $assert->whereContains('foo', ['1']); + } + + public function testAssertWhereContainsFailsWithMissingValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => ['bar', 'baz'], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain [invalid].'); + + $assert->whereContains('foo', ['bar', 'baz', 'invalid']); + } + + public function testAssertWhereContainsFailsWithMissingNestedValue() + { + $assert = AssertableJson::fromArray([ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ['id' => 4], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [id] does not contain [5].'); + + $assert->whereContains('id', [1, 2, 3, 4, 5]); + } + + public function testAssertWhereContainsFailsWhenDoesNotMatchType() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain [1].'); + + $assert->whereContains('foo', ['1']); + } + + public function testAssertWhereContainsFailsWhenDoesNotSatisfyClosure() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain a value that passes the truth test within the given closure.'); + + $assert->whereContains('foo', [function ($actual) { + return $actual === 5; + }]); + } + + public function testAssertWhereContainsFailsWhenHavingExpectedValueButDoesNotSatisfyClosure() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain a value that passes the truth test within the given closure.'); + + $assert->whereContains('foo', [1, function ($actual) { + return $actual === 5; + }]); + } + + public function testAssertWhereContainsFailsWhenSatisfiesClosureButDoesNotHaveExpectedValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] does not contain [5].'); + + $assert->whereContains('foo', [5, function ($actual) { + return $actual === 1; + }]); + } + + public function testAssertWhereContainsWithNestedValue() + { + $assert = AssertableJson::fromArray([ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ['id' => 4], + ]); + + $assert->whereContains('id', 1); + $assert->whereContains('id', [1, 2, 3, 4]); + $assert->whereContains('id', [4, 3, 2, 1]); + } + + public function testAssertWhereContainsWithMatchingType() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $assert->whereContains('foo', 1); + $assert->whereContains('foo', [1]); + } + + public function testAssertWhereContainsWithNullValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => null, + ]); + + $assert->whereContains('foo', null); + $assert->whereContains('foo', [null]); + } + + public function testAssertWhereContainsWithOutOfOrderMatchingType() + { + $assert = AssertableJson::fromArray([ + 'foo' => [4, 1, 7, 3], + ]); + + $assert->whereContains('foo', [1, 7, 4, 3]); + } + + public function testAssertWhereContainsWithOutOfOrderNestedMatchingType() + { + $assert = AssertableJson::fromArray([ + ['bar' => 5], + ['baz' => 4], + ['zal' => 8], + ]); + + $assert->whereContains('baz', 4); + } + + public function testAssertWhereContainsWithClosure() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $assert->whereContains('foo', function ($actual) { + return $actual % 3 === 0; + }); + } + + public function testAssertWhereContainsWithNestedClosure() + { + $assert = AssertableJson::fromArray([ + 'foo' => 1, + 'bar' => 2, + 'baz' => 3, + ]); + + $assert->whereContains('baz', function ($actual) { + return $actual % 3 === 0; + }); + } + + public function testAssertWhereContainsWithMultipleClosure() + { + $assert = AssertableJson::fromArray([ + 'foo' => [1, 2, 3, 4], + ]); + + $assert->whereContains('foo', [ + function ($actual) { + return $actual % 3 === 0; + }, + function ($actual) { + return $actual % 2 === 0; + }, + ]); + } + + public function testAssertWhereContainsWithNullExpectation() + { + $assert = AssertableJson::fromArray([ + 'foo' => 1, + ]); + + $assert->whereContains('foo', null); + } + + public function testAssertNestedWhereMatchesValue() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->where('example.nested', 'nested-value'); + } + + public function testAssertNestedWhereFailsWhenDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.nested] does not match the expected value.'); + + $assert->where('example.nested', 'another-value'); + } + + public function testScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (AssertableJson $assert) use (&$called) { + $called = true; + $assert + ->where('baz', 'example') + ->where('prop', 'value'); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', function (AssertableJson $item) { + $item->where('baz', 'example'); + }); + } + + public function testScopeFailsWhenPropSingleValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] is not scopeable.'); + + $assert->has('bar', function (AssertableJson $item) { + // + }); + } + + public function testScopeShorthand() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $called = false; + $assert->has('bar', 2, function (AssertableJson $item) use (&$called) { + $item->where('key', 'first'); + $called = true; + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeShorthandWithoutCount() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $called = false; + $assert->has('bar', null, function (AssertableJson $item) use (&$called) { + $item->where('key', 'first'); + $called = true; + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeShorthandFailsWhenAssertingZeroItems() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 0, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenAssertingEmptyArray() + { + $assert = AssertableJson::fromArray([ + 'bar' => [], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage( + 'Cannot scope directly onto the first element of property [bar] because it is empty.' + ); + + $assert->has('bar', 0, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenAssertingEmptyArrayWithoutCount() + { + $assert = AssertableJson::fromArray([ + 'bar' => [], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage( + 'Cannot scope directly onto the first element of property [bar] because it is empty.' + ); + + $assert->has('bar', null, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenSecondArgumentUnsupportedType() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(TypeError::class); + + $assert->has('bar', 'invalid', function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testFirstScope() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'key' => 'first', + ], + 'bar' => [ + 'key' => 'second', + ], + ]); + + $assert->first(function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testFirstScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first element of the root level because it is empty.'); + + $assert->first(function (AssertableJson $item) { + // + }); + } + + public function testFirstNestedScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([ + 'foo' => [], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first element of property [foo] because it is empty.'); + + $assert->has('foo', function (AssertableJson $assert) { + $assert->first(function (AssertableJson $item) { + // + }); + }); + } + + public function testFirstScopeFailsWhenPropSingleValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not scopeable.'); + + $assert->first(function (AssertableJson $item) { + // + }); + } + + public function testEachScope() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'key' => 'first', + ], + 'bar' => [ + 'key' => 'second', + ], + ]); + + $assert->each(function (AssertableJson $item) { + $item->whereType('key', 'string'); + }); + } + + public function testEachScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto each element of the root level because it is empty.'); + + $assert->each(function (AssertableJson $item) { + // + }); + } + + public function testEachNestedScopeFailsWhenNoProps() + { + $assert = AssertableJson::fromArray([ + 'foo' => [], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto each element of property [foo] because it is empty.'); + + $assert->has('foo', function (AssertableJson $assert) { + $assert->each(function (AssertableJson $item) { + // + }); + }); + } + + public function testEachScopeFailsWhenPropSingleValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not scopeable.'); + + $assert->each(function (AssertableJson $item) { + // + }); + } + + public function testFailsWhenNotInteractingWithAllPropsInScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar].'); + + $assert->has('bar', function (AssertableJson $item) { + $item->where('baz', 'example'); + }); + } + + public function testDisableInteractionCheckForCurrentScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', function (AssertableJson $item) { + $item->etc(); + }); + } + + public function testCannotDisableInteractionCheckForDifferentScopes() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => [ + 'foo' => 'bar', + 'example' => 'value', + ], + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar.baz].'); + + $assert->has('bar', function (AssertableJson $item) { + $item + ->etc() + ->has('baz', function (AssertableJson $item) { + // + }); + }); + } + + public function testTopLevelPropInteractionDisabledByDefault() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $assert->has('foo'); + } + + public function testTopLevelInteractionEnabledWhenInteractedFlagSet() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $assert + ->has('foo') + ->interacted(); + } + + public function testAssertWhereAllMatchesValues() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->whereAll([ + 'foo.bar' => 'value', + 'foo.example' => ArrayableStubObject::make(['hello' => 'world']), + 'baz' => function ($value) { + return $value === 'another'; + }, + ]); + } + + public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'baz' => 'example', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was marked as invalid using a closure.'); + + $assert->whereAll([ + 'foo' => 'bar', + 'baz' => function ($value) { + return $value === 'foo'; + }, + ]); + } + + public function testAssertWhereTypeString() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $assert->whereType('foo', 'string'); + } + + public function testAssertWhereTypeInteger() + { + $assert = AssertableJson::fromArray([ + 'foo' => 123, + ]); + + $assert->whereType('foo', 'integer'); + } + + public function testAssertWhereTypeBoolean() + { + $assert = AssertableJson::fromArray([ + 'foo' => true, + ]); + + $assert->whereType('foo', 'boolean'); + } + + public function testAssertWhereTypeDouble() + { + $assert = AssertableJson::fromArray([ + 'foo' => 12.3, + ]); + + $assert->whereType('foo', 'double'); + } + + public function testAssertWhereTypeArray() + { + $assert = AssertableJson::fromArray([ + 'foo' => ['bar', 'baz'], + 'bar' => ['foo' => 'baz'], + ]); + + $assert->whereType('foo', 'array'); + $assert->whereType('bar', 'array'); + } + + public function testAssertWhereTypeNull() + { + $assert = AssertableJson::fromArray([ + 'foo' => null, + ]); + + $assert->whereType('foo', 'null'); + } + + public function testAssertWhereAllType() + { + $assert = AssertableJson::fromArray([ + 'one' => 'foo', + 'two' => 123, + 'three' => true, + 'four' => 12.3, + 'five' => ['foo', 'bar'], + 'six' => ['foo' => 'bar'], + 'seven' => null, + ]); + + $assert->whereAllType([ + 'one' => 'string', + 'two' => 'integer', + 'three' => 'boolean', + 'four' => 'double', + 'five' => 'array', + 'six' => 'array', + 'seven' => 'null', + ]); + } + + public function testAssertWhereTypeWhenWrongTypeIsGiven() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not of expected type [integer].'); + + $assert->whereType('foo', 'integer'); + } + + public function testAssertWhereTypeWithUnionTypes() + { + $firstAssert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $secondAssert = AssertableJson::fromArray([ + 'foo' => null, + ]); + + $firstAssert->whereType('foo', ['string', 'null']); + $secondAssert->whereType('foo', ['string', 'null']); + } + + public function testAssertWhereTypeWhenWrongUnionTypeIsGiven() + { + $assert = AssertableJson::fromArray([ + 'foo' => 123, + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not of expected type [string|null].'); + + $assert->whereType('foo', ['string', 'null']); + } + + public function testAssertWhereTypeWithPipeInUnionType() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $assert->whereType('foo', 'string|null'); + } + + public function testAssertWhereTypeWithPipeInWrongUnionType() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo] is not of expected type [integer|null].'); + + $assert->whereType('foo', 'integer|null'); + } + + public function testAssertHasAll() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll([ + 'foo.bar', + 'foo.example', + 'baz', + ]); + } + + public function testAssertHasAllFailsWhenAtLeastOnePropMissing() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll([ + 'foo.bar', + 'foo.baz', + 'baz', + ]); + } + + public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll('foo.bar', 'foo.example', 'baz'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll('foo.bar', 'foo.baz', 'baz'); + } + + public function testAssertCountMultipleProps() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + 'baz' => [ + 'another' => 'value', + ], + ]); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testAssertCountMultiplePropsFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testMacroable() + { + AssertableJson::macro('myCustomMacro', function () { + throw new RuntimeException('My Custom Macro was called!'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('My Custom Macro was called!'); + + $assert = AssertableJson::fromArray(['foo' => 'bar']); + $assert->myCustomMacro(); + } + + public function testTappable() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (AssertableJson $assert) use (&$called) { + $assert->etc(); + $assert->tap(function (AssertableJson $assert) use (&$called) { + $called = true; + }); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } +} diff --git a/tests/Testing/ParallelConsoleOutputTest.php b/tests/Testing/ParallelConsoleOutputTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7e9da0244df1aed0aaed529d914bb9dad42c2a5f --- /dev/null +++ b/tests/Testing/ParallelConsoleOutputTest.php @@ -0,0 +1,25 @@ +<?php + +namespace Illuminate\Tests\Testing; + +use Illuminate\Testing\ParallelConsoleOutput; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\BufferedOutput; + +class ParallelConsoleOutputTest extends TestCase +{ + public function testWrite() + { + $original = new BufferedOutput; + $output = new ParallelConsoleOutput($original); + + $output->write('Running phpunit in 12 processes with laravel/laravel.'); + $this->assertEmpty($original->fetch()); + + $output->write('Configuration read from phpunit.xml.dist'); + $this->assertEmpty($original->fetch()); + + $output->write('... 3/3 (100%)'); + $this->assertSame('... 3/3 (100%)', $original->fetch()); + } +} diff --git a/tests/Testing/ParallelTestingTest.php b/tests/Testing/ParallelTestingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fef3eecd5f7ddbb5ebcbca245b10f155d20b9e5d --- /dev/null +++ b/tests/Testing/ParallelTestingTest.php @@ -0,0 +1,108 @@ +<?php + +namespace Illuminate\Tests\Testing; + +use Illuminate\Container\Container; +use Illuminate\Testing\ParallelTesting; +use PHPUnit\Framework\TestCase; + +class ParallelTestingTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Container::setInstance(new Container); + + $_SERVER['LARAVEL_PARALLEL_TESTING'] = 1; + } + + /** + * @dataProvider callbacks + */ + public function testCallbacks($callback) + { + $parallelTesting = new ParallelTesting(Container::getInstance()); + $caller = 'call'.ucfirst($callback).'Callbacks'; + + $state = false; + $parallelTesting->{$caller}($this); + $this->assertFalse($state); + + $parallelTesting->{$callback}(function ($token, $testCase = null) use ($callback, &$state) { + if (in_array($callback, ['setUpTestCase', 'tearDownTestCase'])) { + $this->assertSame($this, $testCase); + } else { + $this->assertNull($testCase); + } + + $this->assertSame('1', $token); + $state = true; + }); + + $parallelTesting->{$caller}($this); + $this->assertFalse($state); + + $parallelTesting->resolveTokenUsing(function () { + return '1'; + }); + + $parallelTesting->{$caller}($this); + $this->assertTrue($state); + } + + public function testOptions() + { + $parallelTesting = new ParallelTesting(Container::getInstance()); + + $this->assertFalse($parallelTesting->option('recreate_databases')); + $this->assertFalse($parallelTesting->option('without_databases')); + + $parallelTesting->resolveOptionsUsing(function ($option) { + return $option === 'recreate_databases'; + }); + + $this->assertFalse($parallelTesting->option('recreate_caches')); + $this->assertFalse($parallelTesting->option('without_databases')); + $this->assertTrue($parallelTesting->option('recreate_databases')); + + $parallelTesting->resolveOptionsUsing(function ($option) { + return $option === 'without_databases'; + }); + + $this->assertTrue($parallelTesting->option('without_databases')); + } + + public function testToken() + { + $parallelTesting = new ParallelTesting(Container::getInstance()); + + $this->assertFalse($parallelTesting->token()); + + $parallelTesting->resolveTokenUsing(function () { + return '1'; + }); + + $this->assertSame('1', $parallelTesting->token()); + } + + public function callbacks() + { + return [ + ['setUpProcess'], + ['setUpTestCase'], + ['setUpTestDatabase'], + ['tearDownTestCase'], + ['tearDownProcess'], + ]; + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + + unset($_SERVER['LARAVEL_PARALLEL_TESTING']); + } +} diff --git a/tests/Testing/Stubs/ArrayableStubObject.php b/tests/Testing/Stubs/ArrayableStubObject.php new file mode 100644 index 0000000000000000000000000000000000000000..021440e0b28750dcdfc2d09572c70be62fc816c2 --- /dev/null +++ b/tests/Testing/Stubs/ArrayableStubObject.php @@ -0,0 +1,25 @@ +<?php + +namespace Illuminate\Tests\Testing\Stubs; + +use Illuminate\Contracts\Support\Arrayable; + +class ArrayableStubObject implements Arrayable +{ + protected $data; + + public function __construct($data = []) + { + $this->data = $data; + } + + public static function make($data = []) + { + return new self($data); + } + + public function toArray() + { + return $this->data; + } +} diff --git a/tests/Foundation/FoundationTestResponseTest.php b/tests/Testing/TestResponseTest.php similarity index 55% rename from tests/Foundation/FoundationTestResponseTest.php rename to tests/Testing/TestResponseTest.php index 049f778b86f296a3485dd99fe33a9168a3d6a756..32e3d556a3295c6e8663c194076e3bcec746a082 100644 --- a/tests/Foundation/FoundationTestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -1,20 +1,31 @@ <?php -namespace Illuminate\Tests\Foundation; +namespace Illuminate\Tests\Testing; +use Exception; +use Illuminate\Container\Container; use Illuminate\Contracts\View\View; +use Illuminate\Cookie\CookieValuePrefix; use Illuminate\Database\Eloquent\Model; +use Illuminate\Encryption\Encrypter; use Illuminate\Filesystem\Filesystem; -use Illuminate\Foundation\Testing\TestResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response; +use Illuminate\Session\ArraySessionHandler; +use Illuminate\Session\Store; +use Illuminate\Support\MessageBag; +use Illuminate\Support\ViewErrorBag; +use Illuminate\Testing\Fluent\AssertableJson; +use Illuminate\Testing\TestResponse; use JsonSerializable; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Cookie; -class FoundationTestResponseTest extends TestCase +class TestResponseTest extends TestCase { public function testAssertViewIs() { @@ -39,7 +50,8 @@ class FoundationTestResponseTest extends TestCase public function testAssertViewHasModel() { - $model = new class extends Model { + $model = new class extends Model + { public function is($model) { return $this == $model; @@ -130,6 +142,50 @@ class FoundationTestResponseTest extends TestCase $response->assertViewMissing('foo.baz'); } + public function testAssertSee() + { + $response = $this->makeMockResponse([ + 'render' => '<ul><li>foo</li><li>bar</li><li>baz</li><li>foo</li></ul>', + ]); + + $response->assertSee('foo'); + $response->assertSee(['baz', 'bar']); + } + + public function testAssertSeeCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => '<ul><li>foo</li><li>bar</li><li>baz</li><li>foo</li></ul>', + ]); + + $response->assertSee('item'); + $response->assertSee(['not', 'found']); + } + + public function testAssertSeeEscaped() + { + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertSee('laravel & php'); + $response->assertSee(['php & friends', 'laravel & php']); + } + + public function testAssertSeeEscapedCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertSee('foo & bar'); + $response->assertSee(['bar & baz', 'baz & qux']); + } + public function testAssertSeeInOrder() { $response = $this->makeMockResponse([ @@ -166,10 +222,45 @@ class FoundationTestResponseTest extends TestCase public function testAssertSeeText() { $response = $this->makeMockResponse([ - 'render' => 'foo<strong>bar</strong>', + 'render' => 'foo<strong>bar</strong>baz<strong>qux</strong>', ]); $response->assertSeeText('foobar'); + $response->assertSeeText(['bazqux', 'foobar']); + } + + public function testAssertSeeTextCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'foo<strong>bar</strong>', + ]); + + $response->assertSeeText('bazfoo'); + $response->assertSeeText(['bazfoo', 'barqux']); + } + + public function testAssertSeeTextEscaped() + { + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertSeeText('laravel & php'); + $response->assertSeeText(['php & friends', 'laravel & php']); + } + + public function testAssertSeeTextEscapedCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertSeeText('foo & bar'); + $response->assertSeeText(['foo & bar', 'bar & baz']); } public function testAssertSeeTextInOrder() @@ -183,6 +274,15 @@ class FoundationTestResponseTest extends TestCase $response->assertSeeTextInOrder(['foobar', 'baz', 'foo']); } + public function testAssertSeeTextInOrderEscaped() + { + $response = $this->makeMockResponse([ + 'render' => '<strong>laravel & php</strong> <i>phpstorm > sublime</i>', + ]); + + $response->assertSeeTextInOrder(['laravel & php', 'phpstorm > sublime']); + } + public function testAssertSeeTextInOrderCanFail() { $this->expectException(AssertionFailedError::class); @@ -205,13 +305,101 @@ class FoundationTestResponseTest extends TestCase $response->assertSeeTextInOrder(['foobar', 'qux', 'baz']); } + public function testAssertDontSee() + { + $response = $this->makeMockResponse([ + 'render' => '<ul><li>foo</li><li>bar</li><li>baz</li><li>foo</li></ul>', + ]); + + $response->assertDontSee('laravel'); + $response->assertDontSee(['php', 'friends']); + } + + public function testAssertDontSeeCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => '<ul><li>foo</li><li>bar</li><li>baz</li><li>foo</li></ul>', + ]); + + $response->assertDontSee('foo'); + $response->assertDontSee(['baz', 'bar']); + } + + public function testAssertDontSeeEscaped() + { + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertDontSee('foo & bar'); + $response->assertDontSee(['bar & baz', 'foo & bar']); + } + + public function testAssertDontSeeEscapedCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertDontSee('laravel & php'); + $response->assertDontSee(['php & friends', 'laravel & php']); + } + + public function testAssertDontSeeText() + { + $response = $this->makeMockResponse([ + 'render' => 'foo<strong>bar</strong>baz<strong>qux</strong>', + ]); + + $response->assertDontSeeText('laravelphp'); + $response->assertDontSeeText(['phpfriends', 'laravelphp']); + } + + public function testAssertDontSeeTextCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'foo<strong>bar</strong>baz<strong>qux</strong>', + ]); + + $response->assertDontSeeText('foobar'); + $response->assertDontSeeText(['bazqux', 'foobar']); + } + + public function testAssertDontSeeTextEscaped() + { + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertDontSeeText('foo & bar'); + $response->assertDontSeeText(['bar & baz', 'foo & bar']); + } + + public function testAssertDontSeeTextEscapedCanFail() + { + $this->expectException(AssertionFailedError::class); + + $response = $this->makeMockResponse([ + 'render' => 'laravel & php & friends', + ]); + + $response->assertDontSeeText('laravel & php'); + $response->assertDontSeeText(['php & friends', 'laravel & php']); + } + public function testAssertOk() { $statusCode = 500; $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response status code ['.$statusCode.'] does not match expected 200 status code.'); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -227,7 +415,7 @@ class FoundationTestResponseTest extends TestCase $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response status code ['.$statusCode.'] does not match expected 201 status code.'); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -242,7 +430,7 @@ class FoundationTestResponseTest extends TestCase $statusCode = 500; $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response status code ['.$statusCode.'] is not a not found status code.'); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -258,7 +446,7 @@ class FoundationTestResponseTest extends TestCase $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response status code ['.$statusCode.'] is not a forbidden status code.'); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -274,7 +462,7 @@ class FoundationTestResponseTest extends TestCase $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response status code ['.$statusCode.'] is not an unauthorized status code.'); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -284,13 +472,29 @@ class FoundationTestResponseTest extends TestCase $response->assertUnauthorized(); } + public function testAssertUnprocessable() + { + $statusCode = 500; + + $this->expectException(AssertionFailedError::class); + + $this->expectExceptionMessage('Expected response status code'); + + $baseResponse = tap(new Response, function ($response) use ($statusCode) { + $response->setStatusCode($statusCode); + }); + + $response = TestResponse::fromBaseResponse($baseResponse); + $response->assertUnprocessable(); + } + public function testAssertNoContentAsserts204StatusCodeByDefault() { $statusCode = 500; $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage("Expected status code 204 but received {$statusCode}"); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -307,7 +511,7 @@ class FoundationTestResponseTest extends TestCase $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage("Expected status code {$expectedStatusCode} but received {$statusCode}"); + $this->expectExceptionMessage('Expected response status code'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); @@ -339,16 +543,100 @@ class FoundationTestResponseTest extends TestCase $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage("Expected status code {$expectedStatusCode} but received {$statusCode}"); + $this->expectExceptionMessage('Expected response status code'); + + $baseResponse = tap(new Response, function ($response) use ($statusCode) { + $response->setStatusCode($statusCode); + }); + + $response = TestResponse::fromBaseResponse($baseResponse); + $response->assertStatus($expectedStatusCode); + } + + public function testAssertStatusShowsExceptionOnUnexpected500() + { + $statusCode = 500; + $expectedStatusCode = 200; + + $this->expectException(AssertionFailedError::class); + + $this->expectExceptionMessage('Test exception message'); $baseResponse = tap(new Response, function ($response) use ($statusCode) { $response->setStatusCode($statusCode); }); + $exceptions = collect([new Exception('Test exception message')]); + + $response = TestResponse::fromBaseResponse($baseResponse) + ->withExceptions($exceptions); + $response->assertStatus($expectedStatusCode); + } + + public function testAssertStatusShowsErrorsOnUnexpectedErrorRedirect() + { + $statusCode = 302; + $expectedStatusCode = 200; + + $this->expectException(AssertionFailedError::class); + + $this->expectExceptionMessage('The first name field is required.'); + + $baseResponse = tap(new RedirectResponse('/', $statusCode), function ($response) { + $response->setSession(new Store('test-session', new ArraySessionHandler(1))); + $response->withErrors([ + 'first_name' => 'The first name field is required.', + 'last_name' => 'The last name field is required.', + ]); + }); $response = TestResponse::fromBaseResponse($baseResponse); $response->assertStatus($expectedStatusCode); } + public function testAssertStatusShowsJsonErrorsOnUnexpected422() + { + $statusCode = 422; + $expectedStatusCode = 200; + + $this->expectException(AssertionFailedError::class); + + $this->expectExceptionMessage('"The first name field is required."'); + + $baseResponse = new Response( + [ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'first_name' => 'The first name field is required.', + 'last_name' => 'The last name field is required.', + ], + ], + $statusCode + ); + + $response = TestResponse::fromBaseResponse($baseResponse); + $response->assertStatus($expectedStatusCode); + } + + public function testAssertStatusWhenJsonIsFalse() + { + $baseResponse = new Response('false', 200, ['Content-Type' => 'application/json']); + + $response = TestResponse::fromBaseResponse($baseResponse); + $response->assertStatus(200); + } + + public function testAssertStatusWhenJsonIsEncoded() + { + $baseResponse = tap(new Response, function ($response) { + $response->header('Content-Type', 'application/json'); + $response->header('Content-Encoding', 'gzip'); + $response->setContent('b"x£½V*.I,)-V▓R╩¤V¬\x05\x00+ü\x059"'); + }); + + $response = TestResponse::fromBaseResponse($baseResponse); + $response->assertStatus(200); + } + public function testAssertHeader() { $this->expectException(AssertionFailedError::class); @@ -397,13 +685,103 @@ class FoundationTestResponseTest extends TestCase $response->assertJson($resource->jsonSerialize()); } - public function testAssertJsonWithMixed() + public function testAssertJsonWithFluent() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + + $response->assertJson(function (AssertableJson $json) { + $json->where('0.foo', 'foo 0'); + }); + } + + public function testAssertJsonWithFluentFailsWhenNotInteractingWithAllProps() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $response->assertJson(function (AssertableJson $json) { + $json->where('foo', 'bar'); + }); + } + + public function testAssertJsonWithFluentSkipsInteractionWhenTopLevelKeysNonAssociative() + { + $response = TestResponse::fromBaseResponse(new Response([ + ['foo' => 'bar'], + ['foo' => 'baz'], + ])); + + $response->assertJson(function (AssertableJson $json) { + // + }); + } + + public function testAssertJsonWithFluentHasAnyThrows() + { + $response = TestResponse::fromBaseResponse(new Response([])); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('None of properties [data, errors, meta] exist.'); + + $response->assertJson(function (AssertableJson $json) { + $json->hasAny('data', 'errors', 'meta'); + }); + } + + public function testAssertJsonWithFluentHasAnyPasses() + { + $response = TestResponse::fromBaseResponse(new Response([ + 'data' => [], + ])); + + $response->assertJson(function (AssertableJson $json) { + $json->hasAny('data', 'errors', 'meta'); + }); + } + + public function testAssertSimilarJsonWithMixed() { $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); $resource = new JsonSerializableMixedResourcesStub; - $response->assertExactJson($resource->jsonSerialize()); + $expected = $resource->jsonSerialize(); + + $response->assertSimilarJson($expected); + + $expected['bars'][0] = ['bar' => 'foo 2', 'foo' => 'bar 2']; + $expected['bars'][2] = ['bar' => 'foo 0', 'foo' => 'bar 0']; + + $response->assertSimilarJson($expected); + } + + public function testAssertExactJsonWithMixedWhenDataIsExactlySame() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $resource = new JsonSerializableMixedResourcesStub; + + $expected = $resource->jsonSerialize(); + + $response->assertExactJson($expected); + } + + public function testAssertExactJsonWithMixedWhenDataIsSimilar() + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Failed asserting that two strings are equal.'); + + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $resource = new JsonSerializableMixedResourcesStub; + + $expected = $resource->jsonSerialize(); + $expected['bars'][0] = ['bar' => 'foo 2', 'foo' => 'bar 2']; + $expected['bars'][2] = ['bar' => 'foo 0', 'foo' => 'bar 0']; + + $response->assertExactJson($expected); } public function testAssertJsonPath() @@ -426,11 +804,11 @@ class FoundationTestResponseTest extends TestCase $response->assertJsonPath('foobar.foobar_foo', 'foo')->assertJsonPath('foobar.foobar_bar', 'bar'); $response->assertJsonPath('bars', [ - ['foo' => 'bar 0', 'bar' => 'foo 0'], - ['foo' => 'bar 1', 'bar' => 'foo 1'], - ['foo' => 'bar 2', 'bar' => 'foo 2'], + ['bar' => 'foo 0', 'foo' => 'bar 0'], + ['bar' => 'foo 1', 'foo' => 'bar 1'], + ['bar' => 'foo 2', 'foo' => 'bar 2'], ]); - $response->assertJsonPath('bars.0', ['foo' => 'bar 0', 'bar' => 'foo 0']); + $response->assertJsonPath('bars.0', ['bar' => 'foo 0', 'foo' => 'bar 0']); $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceWithIntegersStub)); @@ -439,23 +817,14 @@ class FoundationTestResponseTest extends TestCase $response->assertJsonPath('2.id', 30); } - public function testAssertJsonPathStrict() - { - $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceWithIntegersStub)); - - $response->assertJsonPath('0.id', 10, true); - $response->assertJsonPath('1.id', 20, true); - $response->assertJsonPath('2.id', 30, true); - } - - public function testAssertJsonPathStrictCanFail() + public function testAssertJsonPathCanFail() { $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage('Failed asserting that 10 is identical to \'10\'.'); $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceWithIntegersStub)); - $response->assertJsonPath('0.id', '10', true); + $response->assertJsonPath('0.id', '10'); } public function testAssertJsonFragment() @@ -588,6 +957,59 @@ class FoundationTestResponseTest extends TestCase $testResponse->assertJsonValidationErrors('foo'); } + public function testAssertJsonValidationErrorsUsingAssertInvalid() + { + $data = [ + 'status' => 'ok', + 'errors' => ['foo' => 'oops'], + ]; + + $testResponse = TestResponse::fromBaseResponse( + (new Response('', 200, ['Content-Type' => 'application/json']))->setContent(json_encode($data)) + ); + + $testResponse->assertInvalid('foo'); + } + + public function testAssertSessionValidationErrorsUsingAssertInvalid() + { + app()->instance('session.store', $store = new Store('test-session', new ArraySessionHandler(1))); + + $store->put('errors', $errorBag = new ViewErrorBag); + + $errorBag->put('default', new MessageBag([ + 'first_name' => [ + 'Your first name is required', + 'Your first name must be at least 1 character', + ], + ])); + + $testResponse = TestResponse::fromBaseResponse(new Response); + + $testResponse->assertValid('last_name'); + $testResponse->assertValid(['last_name']); + + $testResponse->assertInvalid(); + $testResponse->assertInvalid('first_name'); + $testResponse->assertInvalid(['first_name']); + $testResponse->assertInvalid(['first_name' => 'required']); + $testResponse->assertInvalid(['first_name' => 'character']); + } + + public function testAssertSessionValidationErrorsUsingAssertValid() + { + app()->instance('session.store', $store = new Store('test-session', new ArraySessionHandler(1))); + + $store->put('errors', $errorBag = new ViewErrorBag); + + $errorBag->put('default', new MessageBag([ + ])); + + $testResponse = TestResponse::fromBaseResponse(new Response); + + $testResponse->assertValid(); + } + public function testAssertJsonValidationErrorsCustomErrorsName() { $data = [ @@ -602,6 +1024,20 @@ class FoundationTestResponseTest extends TestCase $testResponse->assertJsonValidationErrors('foo', 'data'); } + public function testAssertJsonValidationErrorsCustomNestedErrorsName() + { + $data = [ + 'status' => 'ok', + 'data' => ['errors' => ['foo' => 'oops']], + ]; + + $testResponse = TestResponse::fromBaseResponse( + (new Response)->setContent(json_encode($data)) + ); + + $testResponse->assertJsonValidationErrors('foo', 'data.errors'); + } + public function testAssertJsonValidationErrorsCanFail() { $this->expectException(AssertionFailedError::class); @@ -776,6 +1212,45 @@ class FoundationTestResponseTest extends TestCase $testResponse->assertJsonValidationErrors(['one' => 'taylor', 'otwell']); } + public function testAssertJsonValidationErrorMessagesMultipleErrors() + { + $data = [ + 'status' => 'ok', + 'errors' => [ + 'one' => [ + 'First error message.', + 'Second error message.', + ], + ], + ]; + + $testResponse = TestResponse::fromBaseResponse( + (new Response)->setContent(json_encode($data)) + ); + + $testResponse->assertJsonValidationErrors(['one' => ['First error message.', 'Second error message.']]); + } + + public function testAssertJsonValidationErrorMessagesMultipleErrorsCanFail() + { + $this->expectException(AssertionFailedError::class); + + $data = [ + 'status' => 'ok', + 'errors' => [ + 'one' => [ + 'First error message.', + ], + ], + ]; + + $testResponse = TestResponse::fromBaseResponse( + (new Response)->setContent(json_encode($data)) + ); + + $testResponse->assertJsonValidationErrors(['one' => ['First error message.', 'Second error message.']]); + } + public function testAssertJsonMissingValidationErrors() { $baseResponse = tap(new Response, function ($response) { @@ -829,6 +1304,27 @@ class FoundationTestResponseTest extends TestCase $response->assertJsonMissingValidationErrors('bar'); } + public function testAssertJsonMissingValidationErrorsCanFail3() + { + $this->expectException(AssertionFailedError::class); + + $baseResponse = tap(new Response, function ($response) { + $response->setContent( + json_encode([ + 'data' => [ + 'errors' => [ + 'foo' => ['one'], + ], + ], + ]), + ); + }); + + $response = TestResponse::fromBaseResponse($baseResponse); + + $response->assertJsonMissingValidationErrors('foo', 'data.errors'); + } + public function testAssertJsonMissingValidationErrorsWithoutArgument() { $data = ['status' => 'ok']; @@ -890,6 +1386,103 @@ class FoundationTestResponseTest extends TestCase $testResponse->assertJsonMissingValidationErrors('bar', 'data'); } + public function testAssertJsonMissingValidationErrorsNestedCustomErrorsName1() + { + $data = [ + 'status' => 'ok', + 'data' => [ + 'errors' => ['foo' => 'oops'], + ], + ]; + + $testResponse = TestResponse::fromBaseResponse( + (new Response)->setContent(json_encode($data)) + ); + + $testResponse->assertJsonMissingValidationErrors('bar', 'data.errors'); + } + + public function testAssertJsonMissingValidationErrorsNestedCustomErrorsName2() + { + $testResponse = TestResponse::fromBaseResponse( + (new Response)->setContent(json_encode([])) + ); + + $testResponse->assertJsonMissingValidationErrors('bar', 'data.errors'); + } + + public function testAssertDownloadOffered() + { + $files = new Filesystem; + $tempDir = __DIR__.'/tmp'; + $files->makeDirectory($tempDir, 0755, false, true); + $files->put($tempDir.'/file.txt', 'Hello World'); + $testResponse = TestResponse::fromBaseResponse(new Response( + $files->get($tempDir.'/file.txt'), 200, [ + 'Content-Disposition' => 'attachment; filename=file.txt', + ] + )); + $testResponse->assertDownload(); + $files->deleteDirectory($tempDir); + } + + public function testAssertDownloadOfferedWithAFileName() + { + $files = new Filesystem; + $tempDir = __DIR__.'/tmp'; + $files->makeDirectory($tempDir, 0755, false, true); + $files->put($tempDir.'/file.txt', 'Hello World'); + $testResponse = TestResponse::fromBaseResponse(new Response( + $files->get($tempDir.'/file.txt'), 200, [ + 'Content-Disposition' => 'attachment; filename = file.txt', + ] + )); + $testResponse->assertDownload('file.txt'); + $files->deleteDirectory($tempDir); + } + + public function testAssertDownloadOfferedWorksWithBinaryFileResponse() + { + $files = new Filesystem; + $tempDir = __DIR__.'/tmp'; + $files->makeDirectory($tempDir, 0755, false, true); + $files->put($tempDir.'/file.txt', 'Hello World'); + $testResponse = TestResponse::fromBaseResponse(new BinaryFileResponse( + $tempDir.'/file.txt', 200, [], true, 'attachment' + )); + $testResponse->assertDownload('file.txt'); + $files->deleteDirectory($tempDir); + } + + public function testAssertDownloadOfferedFailsWithInlineContentDisposition() + { + $this->expectException(AssertionFailedError::class); + $files = new Filesystem; + $tempDir = __DIR__.'/tmp'; + $files->makeDirectory($tempDir, 0755, false, true); + $files->put($tempDir.'/file.txt', 'Hello World'); + $testResponse = TestResponse::fromBaseResponse(new BinaryFileResponse( + $tempDir.'/file.txt', 200, [], true, 'inline' + )); + $testResponse->assertDownload(); + $files->deleteDirectory($tempDir); + } + + public function testAssertDownloadOfferedWithAFileNameWithSpacesInIt() + { + $files = new Filesystem; + $tempDir = __DIR__.'/tmp'; + $files->makeDirectory($tempDir, 0755, false, true); + $files->put($tempDir.'/file.txt', 'Hello World'); + $testResponse = TestResponse::fromBaseResponse(new Response( + $files->get($tempDir.'/file.txt'), 200, [ + 'Content-Disposition' => 'attachment; filename = "test file.txt"', + ] + )); + $testResponse->assertDownload('test file.txt'); + $files->deleteDirectory($tempDir); + } + public function testMacroable() { TestResponse::macro('foo', function () { @@ -939,6 +1532,92 @@ class FoundationTestResponseTest extends TestCase })->assertStatus(418); } + public function testAssertPlainCookie() + { + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie('cookie-name', 'cookie-value')) + ); + + $response->assertPlainCookie('cookie-name', 'cookie-value'); + } + + public function testAssertCookie() + { + $container = Container::getInstance(); + $encrypter = new Encrypter(str_repeat('a', 16)); + $container->singleton('encrypter', function () use ($encrypter) { + return $encrypter; + }); + + $cookieName = 'cookie-name'; + $cookieValue = 'cookie-value'; + $encryptedValue = $encrypter->encrypt(CookieValuePrefix::create($cookieName, $encrypter->getKey()).$cookieValue, false); + + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie($cookieName, $encryptedValue)) + ); + + $response->assertCookie($cookieName, $cookieValue); + } + + public function testAssertCookieExpired() + { + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie('cookie-name', 'cookie-value', time() - 5000)) + ); + + $response->assertCookieExpired('cookie-name'); + } + + public function testAssertSessionCookieExpiredDoesNotTriggerOnSessionCookies() + { + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie('cookie-name', 'cookie-value', 0)) + ); + + $this->expectException(ExpectationFailedException::class); + + $response->assertCookieExpired('cookie-name'); + } + + public function testAssertCookieNotExpired() + { + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie('cookie-name', 'cookie-value', time() + 5000)) + ); + + $response->assertCookieNotExpired('cookie-name'); + } + + public function testAssertSessionCookieNotExpired() + { + $response = TestResponse::fromBaseResponse( + (new Response)->withCookie(new Cookie('cookie-name', 'cookie-value', 0)) + ); + + $response->assertCookieNotExpired('cookie-name'); + } + + public function testAssertCookieMissing() + { + $response = TestResponse::fromBaseResponse(new Response); + + $response->assertCookieMissing('cookie-name'); + } + + public function testAssertRedirectContains() + { + $response = TestResponse::fromBaseResponse( + (new Response('', 302))->withHeaders(['Location' => 'https://url.com']) + ); + + $response->assertRedirectContains('url.com'); + + $this->expectException(ExpectationFailedException::class); + + $response->assertRedirectContains('url.net'); + } + private function makeMockResponse($content) { $baseResponse = tap(new Response, function ($response) use ($content) { @@ -951,7 +1630,7 @@ class FoundationTestResponseTest extends TestCase class JsonSerializableMixedResourcesStub implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return [ 'foo' => 'bar', @@ -971,7 +1650,7 @@ class JsonSerializableMixedResourcesStub implements JsonSerializable ], 'barfoo' => [ ['bar' => ['bar' => 'foo 0']], - ['bar' => ['bar' => 'foo 0', 'bar' => 'foo 0']], + ['bar' => ['bar' => 'foo 0', 'foo' => 'foo 0']], ['bar' => ['foo' => 'bar 0', 'bar' => 'foo 0', 'rab' => 'rab 0']], ], 'numeric_keys' => [ @@ -985,7 +1664,7 @@ class JsonSerializableMixedResourcesStub implements JsonSerializable class JsonSerializableSingleResourceStub implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return [ ['foo' => 'foo 0', 'bar' => 'bar 0', 'foobar' => 'foobar 0'], @@ -998,7 +1677,7 @@ class JsonSerializableSingleResourceStub implements JsonSerializable class JsonSerializableSingleResourceWithIntegersStub implements JsonSerializable { - public function jsonSerialize() + public function jsonSerialize(): array { return [ ['id' => 10, 'foo' => 'bar'], diff --git a/tests/Translation/TranslationTranslatorTest.php b/tests/Translation/TranslationTranslatorTest.php index a660c8e864aacb2e3ab2046d6888f78e01223adf..f1fbf3e40ca36ed1bf022013e2dd76c6239af037 100755 --- a/tests/Translation/TranslationTranslatorTest.php +++ b/tests/Translation/TranslationTranslatorTest.php @@ -18,19 +18,19 @@ class TranslationTranslatorTest extends TestCase public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull() { - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'))->willReturn('foo'); $this->assertFalse($t->has('foo', 'bar')); - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en', 'sp'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en', 'sp'])->getMock(); $t->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'))->willReturn('bar'); $this->assertTrue($t->has('foo', 'bar')); - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'), false)->willReturn('bar'); $this->assertTrue($t->hasForLocale('foo', 'bar')); - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'), false)->willReturn('foo'); $this->assertFalse($t->hasForLocale('foo', 'bar')); @@ -76,10 +76,10 @@ class TranslationTranslatorTest extends TestCase public function testGetMethodProperlyLoadsAndRetrievesItemWithCapitalization() { - $t = $this->getMockBuilder(Translator::class)->setMethods(null)->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods([])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); - $t->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['foo' => 'foo', 'baz' => 'breeze :Foo :BAR']); - $this->assertSame('breeze Bar FOO', $t->get('foo::bar.baz', ['foo' => 'bar', 'bar' => 'foo'], 'en')); + $t->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['foo' => 'foo', 'baz' => 'breeze :0 :Foo :BAR']); + $this->assertSame('breeze john Bar FOO', $t->get('foo::bar.baz', ['john', 'foo' => 'bar', 'bar' => 'foo'], 'en')); $this->assertSame('foo', $t->get('foo::bar.foo')); } @@ -114,7 +114,7 @@ class TranslationTranslatorTest extends TestCase public function testChoiceMethodProperlyLoadsAndRetrievesItem() { - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo(['replace']), $this->equalTo('en'))->willReturn('line'); $t->setSelector($selector = m::mock(MessageSelector::class)); $selector->shouldReceive('choose')->once()->with('line', 10, 'en')->andReturn('choiced'); @@ -124,7 +124,7 @@ class TranslationTranslatorTest extends TestCase public function testChoiceMethodProperlyCountsCollectionsAndLoadsAndRetrievesItem() { - $t = $this->getMockBuilder(Translator::class)->setMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); + $t = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $t->expects($this->exactly(2))->method('get')->with($this->equalTo('foo'), $this->equalTo(['replace']), $this->equalTo('en'))->willReturn('line'); $t->setSelector($selector = m::mock(MessageSelector::class)); $selector->shouldReceive('choose')->twice()->with('line', 3, 'en')->andReturn('choiced'); @@ -150,6 +150,13 @@ class TranslationTranslatorTest extends TestCase $this->assertSame('bar onetwo three', $t->get('foo :i:c :u', ['i' => 'one', 'c' => 'two', 'u' => 'three'])); } + public function testGetJsonHasAtomicReplacements() + { + $t = new Translator($this->getLoader(), 'en'); + $t->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['Hello :foo!' => 'Hello :foo!']); + $this->assertSame('Hello baz:bar!', $t->get('Hello :foo!', ['foo' => 'baz:bar', 'bar' => 'abcdef'])); + } + public function testGetJsonReplacesForAssociativeInput() { $t = new Translator($this->getLoader(), 'en'); @@ -196,6 +203,14 @@ class TranslationTranslatorTest extends TestCase $this->assertSame('foo baz', $t->get('foo :message', ['message' => 'baz'])); } + public function testEmptyFallbacks() + { + $t = new Translator($this->getLoader(), 'en'); + $t->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $t->getLoader()->shouldReceive('load')->once()->with('en', 'foo :message', '*')->andReturn([]); + $this->assertSame('foo ', $t->get('foo :message', ['message' => null])); + } + protected function getLoader() { return m::mock(Loader::class); diff --git a/tests/Validation/Enums.php b/tests/Validation/Enums.php new file mode 100644 index 0000000000000000000000000000000000000000..ad8f9c34f6e6984633db3c3cb3666da4810b605e --- /dev/null +++ b/tests/Validation/Enums.php @@ -0,0 +1,21 @@ +<?php + +namespace Illuminate\Tests\Validation; + +enum StringStatus: string +{ + case pending = 'pending'; + case done = 'done'; +} + +enum IntegerStatus: int +{ + case pending = 1; + case done = 2; +} + +enum PureEnum +{ + case one; + case two; +} diff --git a/tests/Validation/ValidationAddFailureTest.php b/tests/Validation/ValidationAddFailureTest.php index ec94133a18ed4ff070835f1e8d76c284fb9e160c..20fa5e43706f0c9049ff24e47163775bfe766316 100644 --- a/tests/Validation/ValidationAddFailureTest.php +++ b/tests/Validation/ValidationAddFailureTest.php @@ -25,7 +25,7 @@ class ValidationAddFailureTest extends TestCase $validator = $this->makeValidator(); $method_name = 'addFailure'; $this->assertTrue(method_exists($validator, $method_name)); - $this->assertTrue(is_callable([$validator, $method_name])); + $this->assertIsCallable([$validator, $method_name]); } public function testAddFailureIsFunctional() diff --git a/tests/Validation/ValidationDimensionsRuleTest.php b/tests/Validation/ValidationDimensionsRuleTest.php index 469d060e9bd88b8f3b31183e9a58af2bbfda6d8d..29d518a1608158fc461b66d8fecaf0ad7026bcb1 100644 --- a/tests/Validation/ValidationDimensionsRuleTest.php +++ b/tests/Validation/ValidationDimensionsRuleTest.php @@ -29,5 +29,14 @@ class ValidationDimensionsRuleTest extends TestCase $rule = Rule::dimensions()->minWidth(300)->minHeight(400); $this->assertSame('dimensions:min_width=300,min_height=400', (string) $rule); + + $rule = Rule::dimensions() + ->when(true, function ($rule) { + $rule->height('100'); + }) + ->unless(true, function ($rule) { + $rule->width('200'); + }); + $this->assertSame('dimensions:height=100', (string) $rule); } } diff --git a/tests/Validation/ValidationEnumRuleTest.php b/tests/Validation/ValidationEnumRuleTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a094590a3a426d06e58fd6410b67fbcae58d2742 --- /dev/null +++ b/tests/Validation/ValidationEnumRuleTest.php @@ -0,0 +1,175 @@ +<?php + +namespace Illuminate\Tests\Validation; + +use Illuminate\Container\Container; +use Illuminate\Support\Facades\Facade; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\Rules\Enum; +use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationServiceProvider; +use Illuminate\Validation\Validator; +use PHPUnit\Framework\TestCase; + +if (PHP_VERSION_ID >= 80100) { + include 'Enums.php'; +} + +/** + * @requires PHP >= 8.1 + */ +class ValidationEnumRuleTest extends TestCase +{ + public function testvalidationPassesWhenPassingCorrectEnum() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => 'pending', + 'int_status' => 1, + ], + [ + 'status' => new Enum(StringStatus::class), + 'int_status' => new Enum(IntegerStatus::class), + ] + ); + + $this->assertFalse($v->fails()); + } + + public function testValidationFailsWhenProvidingNoExistingCases() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => 'finished', + ], + [ + 'status' => new Enum(StringStatus::class), + ] + ); + + $this->assertTrue($v->fails()); + $this->assertEquals(['The selected status is invalid.'], $v->messages()->get('status')); + } + + public function testValidationFailsWhenProvidingDifferentType() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => 10, + ], + [ + 'status' => new Enum(StringStatus::class), + ] + ); + + $this->assertTrue($v->fails()); + $this->assertEquals(['The selected status is invalid.'], $v->messages()->get('status')); + } + + public function testValidationPassesWhenProvidingDifferentTypeThatIsCastableToTheEnumType() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => '1', + ], + [ + 'status' => new Enum(IntegerStatus::class), + ] + ); + + $this->assertFalse($v->fails()); + } + + public function testValidationFailsWhenProvidingNull() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => null, + ], + [ + 'status' => new Enum(StringStatus::class), + ] + ); + + $this->assertTrue($v->fails()); + $this->assertEquals(['The selected status is invalid.'], $v->messages()->get('status')); + } + + public function testValidationPassesWhenProvidingNullButTheFieldIsNullable() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => null, + ], + [ + 'status' => ['nullable', new Enum(StringStatus::class)], + ] + ); + + $this->assertFalse($v->fails()); + } + + public function testValidationFailsOnPureEnum() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => 'one', + ], + [ + 'status' => ['required', new Enum(PureEnum::class)], + ] + ); + + $this->assertTrue($v->fails()); + } + + public function testValidationFailsWhenProvidingStringToIntegerType() + { + $v = new Validator( + resolve('translator'), + [ + 'status' => 'abc', + ], + [ + 'status' => new Enum(IntegerStatus::class), + ] + ); + + $this->assertTrue($v->fails()); + $this->assertEquals(['The selected status is invalid.'], $v->messages()->get('status')); + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + + Password::$defaultCallback = null; + } +} diff --git a/tests/Validation/ValidationExistsRuleTest.php b/tests/Validation/ValidationExistsRuleTest.php index d5cce447cfd08b5cbea890bff05047e90b166d5e..04b03584dbb15fe83c0dabc81c368c49093f9af2 100644 --- a/tests/Validation/ValidationExistsRuleTest.php +++ b/tests/Validation/ValidationExistsRuleTest.php @@ -43,6 +43,10 @@ class ValidationExistsRuleTest extends TestCase $rule->where('foo', 'bar'); $this->assertSame('exists:users,NULL,foo,"bar"', (string) $rule); + $rule = new Exists(UserWithPrefixedTable::class); + $rule->where('foo', 'bar'); + $this->assertSame('exists:'.UserWithPrefixedTable::class.',NULL,foo,"bar"', (string) $rule); + $rule = new Exists('table', 'column'); $rule->where('foo', 'bar'); $this->assertSame('exists:table,column,foo,"bar"', (string) $rule); @@ -51,6 +55,10 @@ class ValidationExistsRuleTest extends TestCase $rule->where('foo', 'bar'); $this->assertSame('exists:users,column,foo,"bar"', (string) $rule); + $rule = new Exists(UserWithConnection::class, 'column'); + $rule->where('foo', 'bar'); + $this->assertSame('exists:mysql.users,column,foo,"bar"', (string) $rule); + $rule = new Exists('Illuminate\Tests\Validation\User', 'column'); $rule->where('foo', 'bar'); $this->assertSame('exists:users,column,foo,"bar"', (string) $rule); @@ -112,6 +120,35 @@ class ValidationExistsRuleTest extends TestCase $this->assertTrue($v->passes()); } + public function testItChoosesValidRecordsUsingConditionalModifiers() + { + $rule = new Exists('users', 'id'); + $rule->when(true, function ($rule) { + $rule->whereNotIn('type', ['foo', 'bar']); + }); + $rule->unless(true, function ($rule) { + $rule->whereNotIn('type', ['baz', 'other']); + }); + + User::create(['id' => '1', 'type' => 'foo']); + User::create(['id' => '2', 'type' => 'bar']); + User::create(['id' => '3', 'type' => 'baz']); + User::create(['id' => '4', 'type' => 'other']); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [], ['id' => $rule]); + $v->setPresenceVerifier(new DatabasePresenceVerifier(Eloquent::getConnectionResolver())); + + $v->setData(['id' => 1]); + $this->assertFalse($v->passes()); + $v->setData(['id' => 2]); + $this->assertFalse($v->passes()); + $v->setData(['id' => 3]); + $this->assertTrue($v->passes()); + $v->setData(['id' => 4]); + $this->assertTrue($v->passes()); + } + public function testItChoosesValidRecordsUsingWhereNotInAndWhereNotInRulesTogether() { $rule = new Exists('users', 'id'); @@ -136,6 +173,17 @@ class ValidationExistsRuleTest extends TestCase $this->assertFalse($v->passes()); } + public function testItIgnoresSoftDeletes() + { + $rule = new Exists('table'); + $rule->withoutTrashed(); + $this->assertSame('exists:table,NULL,deleted_at,"NULL"', (string) $rule); + + $rule = new Exists('table'); + $rule->withoutTrashed('softdeleted_at'); + $this->assertSame('exists:table,NULL,softdeleted_at,"NULL"', (string) $rule); + } + protected function createSchema() { $this->schema('default')->create('users', function ($table) { @@ -202,6 +250,18 @@ class User extends Eloquent public $timestamps = false; } +class UserWithPrefixedTable extends Eloquent +{ + protected $table = 'public.users'; + protected $guarded = []; + public $timestamps = false; +} + +class UserWithConnection extends User +{ + protected $connection = 'mysql'; +} + class NoTableNameModel extends Eloquent { protected $guarded = []; diff --git a/tests/Validation/ValidationFactoryTest.php b/tests/Validation/ValidationFactoryTest.php index 5a3019a411fad7266bddbc0cf11840a45d3c5e0b..a309bebdab309de3c224a092844f6e82b6031ca2 100755 --- a/tests/Validation/ValidationFactoryTest.php +++ b/tests/Validation/ValidationFactoryTest.php @@ -73,7 +73,7 @@ class ValidationFactoryTest extends TestCase ['foo' => 'required'] ); - $this->assertEquals($validated, ['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $validated); } public function testCustomResolverIsCalled() diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6bd88adf200c88e920cd94a403b04a419e5e619a --- /dev/null +++ b/tests/Validation/ValidationNotPwnedVerifierTest.php @@ -0,0 +1,146 @@ +<?php + +namespace Illuminate\Tests\Validation; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Factory as HttpFactory; +use Illuminate\Http\Client\Response; +use Illuminate\Validation\NotPwnedVerifier; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class ValidationNotPwnedVerifierTest extends TestCase +{ + protected function tearDown(): void + { + m::close(); + + Container::setInstance(null); + } + + public function testEmptyValues() + { + $httpFactory = m::mock(HttpFactory::class); + $verifier = new NotPwnedVerifier($httpFactory); + + foreach (['', false, 0] as $password) { + $this->assertFalse($verifier->verify([ + 'value' => $password, + 'threshold' => 0, + ])); + } + } + + public function testApiResponseGoesWrong() + { + $httpFactory = m::mock(HttpFactory::class); + $response = m::mock(Response::class); + + $httpFactory = m::mock(HttpFactory::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory + ->shouldReceive('timeout') + ->once() + ->with(30) + ->andReturn($httpFactory); + + $httpFactory->shouldReceive('get') + ->once() + ->andReturn($response); + + $response->shouldReceive('successful') + ->once() + ->andReturn(true); + + $response->shouldReceive('body') + ->once() + ->andReturn(''); + + $verifier = new NotPwnedVerifier($httpFactory); + + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + } + + public function testApiGoesDown() + { + $httpFactory = m::mock(HttpFactory::class); + $response = m::mock(Response::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory + ->shouldReceive('timeout') + ->once() + ->with(30) + ->andReturn($httpFactory); + + $httpFactory->shouldReceive('get') + ->once() + ->andReturn($response); + + $response->shouldReceive('successful') + ->once() + ->andReturn(false); + + $verifier = new NotPwnedVerifier($httpFactory); + + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + } + + public function testDnsDown() + { + $container = Container::getInstance(); + $exception = new ConnectionException(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + $exceptionHandler->shouldReceive('report')->once()->with($exception); + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $httpFactory = m::mock(HttpFactory::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory + ->shouldReceive('timeout') + ->once() + ->with(30) + ->andReturn($httpFactory); + + $httpFactory + ->shouldReceive('get') + ->once() + ->andThrow($exception); + + $verifier = new NotPwnedVerifier($httpFactory); + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + + unset($container[ExceptionHandler::class]); + } +} diff --git a/tests/Validation/ValidationPasswordRuleTest.php b/tests/Validation/ValidationPasswordRuleTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0c111eb6a733e1f76c3d50cf4d28a247b5a9e0db --- /dev/null +++ b/tests/Validation/ValidationPasswordRuleTest.php @@ -0,0 +1,359 @@ +<?php + +namespace Illuminate\Tests\Validation; + +use Illuminate\Container\Container; +use Illuminate\Support\Facades\Facade; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationServiceProvider; +use Illuminate\Validation\Validator; +use PHPUnit\Framework\TestCase; + +class ValidationPasswordRuleTest extends TestCase +{ + public function testString() + { + $this->fails(Password::min(3), [['foo' => 'bar'], ['foo']], [ + 'validation.string', + 'validation.min.string', + ]); + + $this->fails(Password::min(3), [1234567, 545], [ + 'validation.string', + ]); + + $this->passes(Password::min(3), ['abcd', '454qb^', '接2133手田']); + } + + public function testMin() + { + $this->fails(new Password(8), ['a', 'ff', '12'], [ + 'validation.min.string', + ]); + + $this->fails(Password::min(3), ['a', 'ff', '12'], [ + 'validation.min.string', + ]); + + $this->passes(Password::min(3), ['333', 'abcd']); + $this->passes(new Password(8), ['88888888']); + } + + public function testConditional() + { + $is_privileged_user = true; + $rule = (new Password(8))->when($is_privileged_user, function ($rule) { + $rule->symbols(); + }); + + $this->fails($rule, ['aaaaaaaa', '11111111'], [ + 'The my password must contain at least one symbol.', + ]); + + $is_privileged_user = false; + $rule = (new Password(8))->when($is_privileged_user, function ($rule) { + $rule->symbols(); + }); + + $this->passes($rule, ['aaaaaaaa', '11111111']); + } + + public function testMixedCase() + { + $this->fails(Password::min(2)->mixedCase(), ['nn', 'MM'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + ]); + + $this->passes(Password::min(2)->mixedCase(), ['Nn', 'Mn', 'âA']); + } + + public function testLetters() + { + $this->fails(Password::min(2)->letters(), ['11', '22', '^^', '``', '**'], [ + 'The my password must contain at least one letter.', + ]); + + $this->passes(Password::min(2)->letters(), ['1a', 'b2', 'â1', '1 京都府']); + } + + public function testNumbers() + { + $this->fails(Password::min(2)->numbers(), ['aa', 'bb', ' a', '京都府'], [ + 'The my password must contain at least one number.', + ]); + + $this->passes(Password::min(2)->numbers(), ['1a', 'b2', '00', '京都府 1']); + } + + public function testDefaultRules() + { + $this->fails(Password::min(3), [null], [ + 'validation.string', + 'validation.min.string', + ]); + } + + public function testSymbols() + { + $this->fails(Password::min(2)->symbols(), ['ab', '1v'], [ + 'The my password must contain at least one symbol.', + ]); + + $this->passes(Password::min(2)->symbols(), ['n^d', 'd^!', 'âè$', '金廿土弓竹中;']); + } + + // public function testUncompromised() + // { + // $this->fails(Password::min(2)->uncompromised(), [ + // '123456', + // 'password', + // 'welcome', + // 'abc123', + // '123456789', + // '12345678', + // 'nuno', + // ], [ + // 'The given my password has appeared in a data leak. Please choose a different my password.', + // ]); + + // $this->passes(Password::min(2)->uncompromised(9999999), [ + // 'nuno', + // ]); + + // $this->passes(Password::min(2)->uncompromised(), [ + // '手田日尸Z難金木水口火女月土廿卜竹弓一十山', + // '!p8VrB', + // '&xe6VeKWF#n4', + // '%HurHUnw7zM!', + // 'rundeliekend', + // '7Z^k5EvqQ9g%c!Jt9$ufnNpQy#Kf', + // 'NRs*Gz2@hSmB$vVBSPDfqbRtEzk4nF7ZAbM29VMW$BPD%b2U%3VmJAcrY5eZGVxP%z%apnwSX', + // ]); + // } + + public function testMessagesOrder() + { + $makeRules = function () { + return ['required', Password::min(8)->mixedCase()->numbers()]; + }; + + $this->fails($makeRules(), [null], [ + 'validation.required', + ]); + + $this->fails($makeRules(), ['foo', 'azdazd'], [ + 'validation.min.string', + 'The my password must contain at least one uppercase and one lowercase letter.', + 'The my password must contain at least one number.', + ]); + + $this->fails($makeRules(), ['1231231'], [ + 'validation.min.string', + 'The my password must contain at least one uppercase and one lowercase letter.', + ]); + + $this->fails($makeRules(), ['4564654564564'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + ]); + + $this->fails($makeRules(), ['aaaaaaaaa', 'TJQSJQSIUQHS'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + 'The my password must contain at least one number.', + ]); + + $this->passes($makeRules(), ['4564654564564Abc']); + + $makeRules = function () { + return ['nullable', 'confirmed', Password::min(8)->letters()->symbols()->uncompromised()]; + }; + + $this->passes($makeRules(), [null]); + + $this->fails($makeRules(), ['foo', 'azdazd'], [ + 'validation.min.string', + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['1231231'], [ + 'validation.min.string', + 'The my password must contain at least one letter.', + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['aaaaaaaaa', 'TJQSJQSIUQHS'], [ + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['4564654564564'], [ + 'The my password must contain at least one letter.', + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['abcabcabc!'], [ + 'The given my password has appeared in a data leak. Please choose a different my password.', + ]); + + $v = new Validator( + resolve('translator'), + ['my_password' => 'Nuno'], + ['my_password' => ['nullable', 'confirmed', Password::min(3)->letters()]] + ); + + $this->assertFalse($v->passes()); + + $this->assertSame( + ['my_password' => ['validation.confirmed']], + $v->messages()->toArray() + ); + } + + public function testItCanUseDefault() + { + $this->assertInstanceOf(Password::class, Password::default()); + } + + public function testItCanSetDefaultUsing() + { + $this->assertInstanceOf(Password::class, Password::default()); + + $password = Password::min(3); + $password2 = Password::min(2)->mixedCase(); + + Password::defaults(function () use ($password) { + return $password; + }); + + $this->passes(Password::default(), ['abcd', '454qb^', '接2133手田']); + $this->assertSame($password, Password::default()); + $this->assertSame(['required', $password], Password::required()); + $this->assertSame(['sometimes', $password], Password::sometimes()); + + Password::defaults($password2); + $this->passes(Password::default(), ['Nn', 'Mn', 'âA']); + $this->assertSame($password2, Password::default()); + $this->assertSame(['required', $password2], Password::required()); + $this->assertSame(['sometimes', $password2], Password::sometimes()); + } + + public function testItCannotSetDefaultUsingGivenString() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('given callback should be callable'); + + Password::defaults('required|password'); + } + + public function testItPassesWithValidDataIfTheSameValidationRulesAreReused() + { + $rules = [ + 'password' => Password::default(), + ]; + + $v = new Validator( + resolve('translator'), + ['password' => '1234'], + $rules + ); + + $this->assertFalse($v->passes()); + + $v1 = new Validator( + resolve('translator'), + ['password' => '12341234'], + $rules + ); + + $this->assertTrue($v1->passes()); + } + + public function testPassesWithCustomRules() + { + $closureRule = function ($attribute, $value, $fail) { + if ($value !== 'aa') { + $fail('Custom rule closure failed'); + } + }; + + $ruleObject = new class implements \Illuminate\Contracts\Validation\Rule + { + public function passes($attribute, $value) + { + return $value === 'aa'; + } + + public function message() + { + return 'Custom rule object failed'; + } + }; + + $this->passes(Password::min(2)->rules($closureRule), ['aa']); + $this->passes(Password::min(2)->rules([$closureRule]), ['aa']); + $this->passes(Password::min(2)->rules($ruleObject), ['aa']); + $this->passes(Password::min(2)->rules([$closureRule, $ruleObject]), ['aa']); + + $this->fails(Password::min(2)->rules($closureRule), ['ab'], [ + 'Custom rule closure failed', + ]); + + $this->fails(Password::min(2)->rules($ruleObject), ['ab'], [ + 'Custom rule object failed', + ]); + } + + protected function passes($rule, $values) + { + $this->assertValidationRules($rule, $values, true, []); + } + + protected function fails($rule, $values, $messages) + { + $this->assertValidationRules($rule, $values, false, $messages); + } + + protected function assertValidationRules($rule, $values, $result, $messages) + { + foreach ($values as $value) { + $v = new Validator( + resolve('translator'), + ['my_password' => $value, 'my_password_confirmation' => $value], + ['my_password' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_password' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + + Password::$defaultCallback = null; + } +} diff --git a/tests/Validation/ValidationRequiredIfTest.php b/tests/Validation/ValidationRequiredIfTest.php index b27cc6d4d57fc8d044d3e0d475b8f2206e4af42b..9d9184b1a8891d59a0aa7694cdfe08629d3ab7fa 100644 --- a/tests/Validation/ValidationRequiredIfTest.php +++ b/tests/Validation/ValidationRequiredIfTest.php @@ -29,4 +29,24 @@ class ValidationRequiredIfTest extends TestCase $this->assertSame('', (string) $rule); } + + public function testItOnlyCallableAndBooleanAreAcceptableArgumentsOfTheRule() + { + $rule = new RequiredIf(false); + + $rule = new RequiredIf(true); + + $this->expectException(\InvalidArgumentException::class); + + $rule = new RequiredIf('phpinfo'); + } + + public function testItReturnedRuleIsNotSerializable() + { + $this->expectException(\Exception::class); + + $rule = serialize(new RequiredIf(function () { + return true; + })); + } } diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..01dc86f126923dfbaa51b3a40a6ea68328080cc5 --- /dev/null +++ b/tests/Validation/ValidationRuleParserTest.php @@ -0,0 +1,83 @@ +<?php + +namespace Illuminate\Tests\Validation; + +use Illuminate\Support\Fluent; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationRuleParser; +use PHPUnit\Framework\TestCase; + +class ValidationRuleParserTest extends TestCase +{ + public function test_conditional_rules_are_properly_expanded_and_filtered() + { + $rules = ValidationRuleParser::filterConditionalRules([ + 'name' => Rule::when(true, ['required', 'min:2']), + 'email' => Rule::when(false, ['required', 'min:2']), + 'password' => Rule::when(true, 'required|min:2'), + 'username' => ['required', Rule::when(true, ['min:2'])], + 'address' => ['required', Rule::when(false, ['min:2'])], + 'city' => ['required', Rule::when(function (Fluent $input) { + return true; + }, ['min:2'])], + ]); + + $this->assertEquals([ + 'name' => ['required', 'min:2'], + 'email' => [], + 'password' => ['required', 'min:2'], + 'username' => ['required', 'min:2'], + 'address' => ['required'], + 'city' => ['required', 'min:2'], + ], $rules); + } + + public function test_empty_rules_are_preserved() + { + $rules = ValidationRuleParser::filterConditionalRules([ + 'name' => [], + 'email' => '', + 'password' => Rule::when(true, 'required|min:2'), + ]); + + $this->assertEquals([ + 'name' => [], + 'email' => '', + 'password' => ['required', 'min:2'], + ], $rules); + } + + public function test_conditional_rules_with_default() + { + $rules = ValidationRuleParser::filterConditionalRules([ + 'name' => Rule::when(true, ['required', 'min:2'], ['string', 'max:10']), + 'email' => Rule::when(false, ['required', 'min:2'], ['string', 'max:10']), + 'password' => Rule::when(false, 'required|min:2', 'string|max:10'), + 'username' => ['required', Rule::when(true, ['min:2'], ['string', 'max:10'])], + 'address' => ['required', Rule::when(false, ['min:2'], ['string', 'max:10'])], + ]); + + $this->assertEquals([ + 'name' => ['required', 'min:2'], + 'email' => ['string', 'max:10'], + 'password' => ['string', 'max:10'], + 'username' => ['required', 'min:2'], + 'address' => ['required', 'string', 'max:10'], + ], $rules); + } + + public function test_empty_conditional_rules_are_preserved() + { + $rules = ValidationRuleParser::filterConditionalRules([ + 'name' => Rule::when(true, '', ['string', 'max:10']), + 'email' => Rule::when(false, ['required', 'min:2'], []), + 'password' => Rule::when(false, 'required|min:2', 'string|max:10'), + ]); + + $this->assertEquals([ + 'name' => [], + 'email' => [], + 'password' => ['string', 'max:10'], + ], $rules); + } +} diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index c967ab7c077cf86c3715514731970c6ce61f658e..cea86c7e11c6aa636cc4603ba6451f7bb894d6ad 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -35,11 +35,24 @@ class ValidationUniqueRuleTest extends TestCase $rule->where('foo', 'bar'); $this->assertSame('unique:table,column,"Taylor, Otwell",id_column,foo,"bar"', (string) $rule); + $rule = new Unique(PrefixedTableEloquentModelStub::class); + $this->assertSame('unique:'.PrefixedTableEloquentModelStub::class.',NULL,NULL,id', (string) $rule); + $rule = new Unique(EloquentModelStub::class, 'column'); $rule->ignore('Taylor, Otwell', 'id_column'); $rule->where('foo', 'bar'); $this->assertSame('unique:table,column,"Taylor, Otwell",id_column,foo,"bar"', (string) $rule); + $rule = new Unique(EloquentModelStub::class, 'column'); + $rule->where('foo', 'bar'); + $rule->when(true, function ($rule) { + $rule->ignore('Taylor, Otwell', 'id_column'); + }); + $rule->unless(true, function ($rule) { + $rule->ignore('Chris', 'id_column'); + }); + $this->assertSame('unique:table,column,"Taylor, Otwell",id_column,foo,"bar"', (string) $rule); + $rule = new Unique('table', 'column'); $rule->ignore('Taylor, Otwell"\'..-"', 'id_column'); $rule->where('foo', 'bar'); @@ -68,6 +81,17 @@ class ValidationUniqueRuleTest extends TestCase $rule->where('foo', '"bar"'); $this->assertSame('unique:table,NULL,NULL,id,foo,"""bar"""', (string) $rule); } + + public function testItIgnoresSoftDeletes() + { + $rule = new Unique('table'); + $rule->withoutTrashed(); + $this->assertSame('unique:table,NULL,NULL,id,deleted_at,"NULL"', (string) $rule); + + $rule = new Unique('table'); + $rule->withoutTrashed('softdeleted_at'); + $this->assertSame('unique:table,NULL,NULL,id,softdeleted_at,"NULL"', (string) $rule); + } } class EloquentModelStub extends Model @@ -77,6 +101,13 @@ class EloquentModelStub extends Model protected $guarded = []; } +class PrefixedTableEloquentModelStub extends Model +{ + protected $table = 'public.table'; + protected $primaryKey = 'id_column'; + protected $guarded = []; +} + class NoTableName extends Model { protected $guarded = []; diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index a176ce42a05572dc21c902b071cb4ce80f2e1ef6..924329d6a537c00464de35482bab6f5a1e0964d0 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4,19 +4,22 @@ namespace Illuminate\Tests\Validation; use DateTime; use DateTimeImmutable; +use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Translation\Translator as TranslatorContract; +use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ImplicitRule; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidatorAwareRule; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Translation\ArrayLoader; use Illuminate\Translation\Translator; -use Illuminate\Validation\PresenceVerifierInterface; +use Illuminate\Validation\DatabasePresenceVerifierInterface; use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\ValidationData; @@ -25,6 +28,7 @@ use Illuminate\Validation\Validator; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\TestCase; +use stdClass; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -59,7 +63,7 @@ class ValidationValidatorTest extends TestCase ]); $this->assertFalse($v->passes()); - $this->assertEquals('post name is required', $v->errors()->all()[0]); + $this->assertSame('post name is required', $v->errors()->all()[0]); } public function testSometimesWorksOnNestedArrays() @@ -120,7 +124,7 @@ class ValidationValidatorTest extends TestCase $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['foo' => 'bar'], ['foo' => 'required']); - $v->validate(); + $this->assertSame(['foo' => 'bar'], $v->validate()); } public function testHasFailedValidationRules() @@ -292,7 +296,7 @@ class ValidationValidatorTest extends TestCase public function testNestedAttributesAreReplacedInDimensions() { // Knowing that demo image.png has width = 3 and height = 2 - $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image.png', '', null, null, null, true); + $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image.png', '', null, null, true); $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.dimensions' => ':min_width :max_height :ratio'], 'en'); @@ -325,7 +329,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('Name is required!', $v->messages()->first('name')); - //set customAttributes by setter + // set customAttributes by setter $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required' => ':attribute is required!'], 'en'); $customAttributes = ['name' => 'Name']; @@ -427,12 +431,33 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['email' => null], ['email' => 'email']); $this->assertFalse($v->passes()); $v->messages()->setFormat(':message'); - $this->assertSame(' is not a valid email', $v->messages()->first('email')); + $this->assertSame('empty is not a valid email', $v->messages()->first('email')); + } + + public function testInputIsReplacedByItsDisplayableValue() + { + $frameworks = [ + 1 => 'Laravel', + 2 => 'Symfony', + 3 => 'Rails', + ]; + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.framework_php' => ':input is not a valid PHP Framework'], 'en'); + + $v = new Validator($trans, ['framework' => 3], ['framework' => 'framework_php']); + $v->addExtension('framework_php', function ($attribute, $value, $parameters, $validator) { + return in_array($value, [1, 2]); + }); + $v->addCustomValues(['framework' => $frameworks]); + + $this->assertFalse($v->passes()); + $this->assertSame('Rails is not a valid PHP Framework', $v->messages()->first('framework')); } public function testDisplayableValuesAreReplaced() { - //required_if:foo,bar + // required_if:foo,bar $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); $trans->addLines(['validation.values.color.1' => 'red'], 'en'); @@ -441,7 +466,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('The bar field is required when color is red.', $v->messages()->first('bar')); - //required_if:foo,boolean + // required_if:foo,boolean $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); $trans->addLines(['validation.values.subscribe.false' => 'false'], 'en'); @@ -458,7 +483,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('The bar field is required when subscribe is true.', $v->messages()->first('bar')); - //required_unless:foo,bar + // required_unless:foo,bar $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_unless' => 'The :attribute field is required unless :other is in :values.'], 'en'); $trans->addLines(['validation.values.color.1' => 'red'], 'en'); @@ -467,7 +492,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('The bar field is required unless color is in red.', $v->messages()->first('bar')); - //in:foo,bar,... + // in:foo,bar,... $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.in' => ':attribute must be included in :values.'], 'en'); $trans->addLines(['validation.values.type.5' => 'Short'], 'en'); @@ -477,7 +502,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('type must be included in Short, Long.', $v->messages()->first('type')); - //date_equals:tomorrow + // date_equals:tomorrow $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.date_equals' => 'The :attribute must be a date equal to :date.'], 'en'); $trans->addLines(['validation.values.date.tomorrow' => 'the day after today'], 'en'); @@ -602,6 +627,34 @@ class ValidationValidatorTest extends TestCase $this->assertSame('english is required!', $v->messages()->first('lang.en')); } + public function testCustomException() + { + $trans = $this->getIlluminateArrayTranslator(); + + $v = new Validator($trans, ['name' => ''], ['name' => 'required']); + + $exception = new class($v) extends ValidationException {}; + $v->setException($exception); + + try { + $v->validate(); + } catch (ValidationException $e) { + $this->assertSame($exception, $e); + } + } + + public function testCustomExceptionMustExtendValidationException() + { + $trans = $this->getIlluminateArrayTranslator(); + + $v = new Validator($trans, [], []); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Exception [RuntimeException] is invalid. It must extend [Illuminate\Validation\ValidationException].'); + + $v->setException(\RuntimeException::class); + } + public function testValidationDotCustomDotAnythingCanBeTranslated() { $trans = $this->getIlluminateArrayTranslator(); @@ -676,6 +729,117 @@ class ValidationValidatorTest extends TestCase $this->assertFalse($v->passes()); } + public function testValidateArrayKeys() + { + $trans = $this->getIlluminateArrayTranslator(); + $rules = ['user' => 'array:name,username']; + + $v = new Validator($trans, ['user' => ['name' => 'Duilio', 'username' => 'duilio']], $rules); + $this->assertTrue($v->passes()); + + // The array is valid if there's a missing key. + $v = new Validator($trans, ['user' => ['name' => 'Duilio']], $rules); + $this->assertTrue($v->passes()); + + // But it's not valid if there's an unexpected key. + $v = new Validator($trans, ['user' => ['name' => 'Duilio', 'username' => 'duilio', 'is_admin' => true]], $rules); + $this->assertFalse($v->passes()); + } + + public function testValidateCurrentPassword() + { + // Fails when user is not logged in. + $auth = m::mock(Guard::class); + $auth->shouldReceive('guard')->andReturn($auth); + $auth->shouldReceive('guest')->andReturn(true); + + $hasher = m::mock(Hasher::class); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->with('auth')->andReturn($auth); + $container->shouldReceive('make')->with('hash')->andReturn($hasher); + + $trans = $this->getTranslator(); + $trans->shouldReceive('get')->andReturnArg(0); + + $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password']); + $v->setContainer($container); + + $this->assertFalse($v->passes()); + + // Fails when password is incorrect. + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword'); + + $auth = m::mock(Guard::class); + $auth->shouldReceive('guard')->andReturn($auth); + $auth->shouldReceive('guest')->andReturn(false); + $auth->shouldReceive('user')->andReturn($user); + + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('check')->andReturn(false); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->with('auth')->andReturn($auth); + $container->shouldReceive('make')->with('hash')->andReturn($hasher); + + $trans = $this->getTranslator(); + $trans->shouldReceive('get')->andReturnArg(0); + + $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password']); + $v->setContainer($container); + + $this->assertFalse($v->passes()); + + // Succeeds when password is correct. + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword'); + + $auth = m::mock(Guard::class); + $auth->shouldReceive('guard')->andReturn($auth); + $auth->shouldReceive('guest')->andReturn(false); + $auth->shouldReceive('user')->andReturn($user); + + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('check')->andReturn(true); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->with('auth')->andReturn($auth); + $container->shouldReceive('make')->with('hash')->andReturn($hasher); + + $trans = $this->getTranslator(); + $trans->shouldReceive('get')->andReturnArg(0); + + $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password']); + $v->setContainer($container); + + $this->assertTrue($v->passes()); + + // We can use a specific guard. + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword'); + + $auth = m::mock(Guard::class); + $auth->shouldReceive('guard')->with('custom')->andReturn($auth); + $auth->shouldReceive('guest')->andReturn(false); + $auth->shouldReceive('user')->andReturn($user); + + $hasher = m::mock(Hasher::class); + $hasher->shouldReceive('check')->andReturn(true); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->with('auth')->andReturn($auth); + $container->shouldReceive('make')->with('hash')->andReturn($hasher); + + $trans = $this->getTranslator(); + $trans->shouldReceive('get')->andReturnArg(0); + + $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password:custom']); + $v->setContainer($container); + + $this->assertTrue($v->passes()); + } + public function testValidateFilled() { $trans = $this->getIlluminateArrayTranslator(); @@ -730,7 +894,7 @@ class ValidationValidatorTest extends TestCase $container->shouldReceive('make')->with('hash')->andReturn($hasher); $trans = $this->getTranslator(); - $trans->shouldReceive('get'); + $trans->shouldReceive('get')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'password']); $v->setContainer($container); @@ -754,7 +918,7 @@ class ValidationValidatorTest extends TestCase $container->shouldReceive('make')->with('hash')->andReturn($hasher); $trans = $this->getTranslator(); - $trans->shouldReceive('get'); + $trans->shouldReceive('get')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'password']); $v->setContainer($container); @@ -778,7 +942,7 @@ class ValidationValidatorTest extends TestCase $container->shouldReceive('make')->with('hash')->andReturn($hasher); $trans = $this->getTranslator(); - $trans->shouldReceive('get'); + $trans->shouldReceive('get')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'password']); $v->setContainer($container); @@ -802,7 +966,7 @@ class ValidationValidatorTest extends TestCase $container->shouldReceive('make')->with('hash')->andReturn($hasher); $trans = $this->getTranslator(); - $trans->shouldReceive('get'); + $trans->shouldReceive('get')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'password:custom']); $v->setContainer($container); @@ -1056,16 +1220,82 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,false']); $this->assertTrue($v->passes()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,null']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 0], ['bar' => 'required_if:foo,0']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '0'], ['bar' => 'required_if:foo,0']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 1], ['bar' => 'required_if:foo,1']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '1'], ['bar' => 'required_if:foo,1']); + $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,true']); $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false], ['bar' => 'required_if:foo,false']); + $this->assertTrue($v->fails()); + // error message when passed multiple values (required_if:foo,bar,baz) $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); $v = new Validator($trans, ['first' => 'dayle', 'last' => ''], ['last' => 'RequiredIf:first,taylor,dayle']); $this->assertFalse($v->passes()); $this->assertSame('The last field is required when first is dayle.', $v->messages()->first('last')); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 0], [ + 'foo' => 'nullable|required|boolean', + 'bar' => 'required_if:foo,true', + 'baz' => 'required_if:foo,false', + ]); + $this->assertTrue($v->fails()); + $this->assertCount(1, $v->messages()); + $this->assertSame('The baz field is required when foo is 0.', $v->messages()->first('baz')); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [], [ + 'foo' => 'nullable|boolean', + 'baz' => 'nullable|required_if:foo,false', + ]); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => null], [ + 'foo' => 'nullable|boolean', + 'baz' => 'nullable|required_if:foo,false', + ]); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [], [ + 'foo' => 'nullable|boolean', + 'baz' => 'nullable|required_if:foo,null', + ]); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => null], [ + 'foo' => 'nullable|boolean', + 'baz' => 'nullable|required_if:foo,null', + ]); + $this->assertTrue($v->fails()); + $this->assertCount(1, $v->messages()); + $this->assertSame('The baz field is required when foo is empty.', $v->messages()->first('baz')); } public function testRequiredUnless() @@ -1098,6 +1328,38 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => false], ['bar' => 'required_unless:foo,true']); $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['bar' => '1'], ['bar' => 'required_unless:foo,true']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [], ['bar' => 'required_unless:foo,true']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [], ['bar' => 'required_unless:foo,null']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true], ['bar' => 'required_unless:foo,null']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '0'], ['bar' => 'required_unless:foo,0']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 0], ['bar' => 'required_unless:foo,0']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '1'], ['bar' => 'required_unless:foo,1']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 1], ['bar' => 'required_unless:foo,1']); + $this->assertTrue($v->passes()); + // error message when passed multiple values (required_unless:foo,bar,baz) $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_unless' => 'The :attribute field is required unless :other is in :values.'], 'en'); @@ -1106,89 +1368,243 @@ class ValidationValidatorTest extends TestCase $this->assertSame('The last field is required unless first is in taylor, sven.', $v->messages()->first('last')); } - public function testFailedFileUploads() + public function testProhibited() { $trans = $this->getIlluminateArrayTranslator(); - // If file is not successfully uploaded validation should fail with a - // 'uploaded' error message instead of the original rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); - $file->shouldNotReceive('getSize'); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); + $v = new Validator($trans, [], ['name' => 'prohibited']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['last' => 'bar'], ['name' => 'prohibited']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['name' => 'foo'], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); - // Even "required" will not run if the file failed to upload. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->once()->andReturn(false); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'required']); + $file = new File('', false); + $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); - // It should only fail with that rule if a validation rule implies it's - // a file. Otherwise it should fail with the regular rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'string']); + $file = new File(__FILE__, false); + $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $this->assertEquals(['validation.string'], $v->errors()->get('photo')); - // Validation shouldn't continue if a file failed to upload. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->once()->andReturn(false); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'file|mimes:pdf|min:10']); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); + $v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'prohibited', 'files.1' => 'prohibited']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['files' => [$file, $file2]], ['files' => 'prohibited']); $this->assertTrue($v->fails()); - $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); } - public function testValidateInArray() + public function testProhibitedIf() { $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => [1, 2, 3], 'bar' => [1, 2]], ['foo.*' => 'in_array:bar.*']); - $this->assertFalse($v->passes()); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_if:first,taylor']); + $this->assertTrue($v->fails()); $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => [1, 2], 'bar' => [1, 2, 3]], ['foo.*' => 'in_array:bar.*']); + $v = new Validator($trans, ['first' => 'taylor'], ['last' => 'prohibited_if:first,taylor']); $this->assertTrue($v->passes()); $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => [['bar_id' => 5], ['bar_id' => 2]], 'bar' => [['id' => 1, ['id' => 2]]]], ['foo.*.bar_id' => 'in_array:bar.*.id']); - $this->assertFalse($v->passes()); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_if:first,taylor,jess']); + $this->assertTrue($v->fails()); $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => [['bar_id' => 1], ['bar_id' => 2]], 'bar' => [['id' => 1, ['id' => 2]]]], ['foo.*.bar_id' => 'in_array:bar.*.id']); + $v = new Validator($trans, ['first' => 'taylor'], ['last' => 'prohibited_if:first,taylor,jess']); $this->assertTrue($v->passes()); - $trans->addLines(['validation.in_array' => 'The value of :attribute does not exist in :other.'], 'en'); - $v = new Validator($trans, ['foo' => [1, 2, 3], 'bar' => [1, 2]], ['foo.*' => 'in_array:bar.*']); - $this->assertSame('The value of foo.2 does not exist in bar.*.', $v->messages()->first('foo.2')); - } - - public function testValidateConfirmed() - { $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['password' => 'foo'], ['password' => 'Confirmed']); - $this->assertFalse($v->passes()); - - $v = new Validator($trans, ['password' => 'foo', 'password_confirmation' => 'bar'], ['password' => 'Confirmed']); - $this->assertFalse($v->passes()); - - $v = new Validator($trans, ['password' => 'foo', 'password_confirmation' => 'foo'], ['password' => 'Confirmed']); + $v = new Validator($trans, ['foo' => true, 'bar' => 'baz'], ['bar' => 'prohibited_if:foo,false']); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['password' => '1e2', 'password_confirmation' => '100'], ['password' => 'Confirmed']); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true, 'bar' => 'baz'], ['bar' => 'prohibited_if:foo,true']); + $this->assertTrue($v->fails()); + + // error message when passed multiple values (prohibited_if:foo,bar,baz) + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.prohibited_if' => 'The :attribute field is prohibited when :other is :value.'], 'en'); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_if:first,taylor,jess']); $this->assertFalse($v->passes()); + $this->assertSame('The last field is prohibited when first is jess.', $v->messages()->first('last')); } - public function testValidateSame() + public function testProhibitedUnless() { $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => 'bar', 'baz' => 'boom'], ['foo' => 'Same:baz']); - $this->assertFalse($v->passes()); - - $v = new Validator($trans, ['foo' => 'bar'], ['foo' => 'Same:baz']); - $this->assertFalse($v->passes()); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'jess'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_unless:first,taylor,jess']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_unless:first,taylor,jess']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false, 'bar' => 'baz'], ['bar' => 'prohibited_unless:foo,false']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false, 'bar' => 'baz'], ['bar' => 'prohibited_unless:foo,true']); + $this->assertTrue($v->fails()); + + // error message when passed multiple values (prohibited_unless:foo,bar,baz) + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.'], 'en'); + $v = new Validator($trans, ['first' => 'tim', 'last' => 'macdonald'], ['last' => 'prohibitedUnless:first,taylor,jess']); + $this->assertFalse($v->passes()); + $this->assertSame('The last field is prohibited unless first is in taylor, jess.', $v->messages()->first('last')); + } + + public function testProhibits() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => ['foo']], ['email' => 'prohibits:emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => []], ['email' => 'prohibits:emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => ''], ['email' => 'prohibits:emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => null], ['email' => 'prohibits:emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => false], ['email' => 'prohibits:emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'emails' => ['foo']], ['email' => 'prohibits:email_address,emails']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo'], ['email' => 'prohibits:emails']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'foo', 'other' => 'foo'], ['email' => 'prohibits:email_address,emails']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.prohibits' => 'The :attribute field prohibits :other being present.'], 'en'); + $v = new Validator($trans, ['email' => 'foo', 'emails' => 'bar', 'email_address' => 'baz'], ['email' => 'prohibits:emails,email_address']); + $this->assertFalse($v->passes()); + $this->assertSame('The email field prohibits emails / email address being present.', $v->messages()->first('email')); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, [ + 'foo' => [ + ['email' => 'foo', 'emails' => 'foo'], + ['emails' => 'foo'], + ], + ], ['foo.*.email' => 'prohibits:foo.*.emails']); + $this->assertFalse($v->passes()); + $this->assertTrue($v->messages()->has('foo.0.email')); + $this->assertFalse($v->messages()->has('foo.1.email')); + } + + public function testFailedFileUploads() + { + $trans = $this->getIlluminateArrayTranslator(); + + // If file is not successfully uploaded validation should fail with a + // 'uploaded' error message instead of the original rule. + $file = m::mock(UploadedFile::class); + $file->shouldReceive('isValid')->andReturn(false); + $file->shouldNotReceive('getSize'); + $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); + $this->assertTrue($v->fails()); + $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); + + // Even "required" will not run if the file failed to upload. + $file = m::mock(UploadedFile::class); + $file->shouldReceive('isValid')->once()->andReturn(false); + $v = new Validator($trans, ['photo' => $file], ['photo' => 'required']); + $this->assertTrue($v->fails()); + $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); + + // It should only fail with that rule if a validation rule implies it's + // a file. Otherwise it should fail with the regular rule. + $file = m::mock(UploadedFile::class); + $file->shouldReceive('isValid')->andReturn(false); + $v = new Validator($trans, ['photo' => $file], ['photo' => 'string']); + $this->assertTrue($v->fails()); + $this->assertEquals(['validation.string'], $v->errors()->get('photo')); + + // Validation shouldn't continue if a file failed to upload. + $file = m::mock(UploadedFile::class); + $file->shouldReceive('isValid')->once()->andReturn(false); + $v = new Validator($trans, ['photo' => $file], ['photo' => 'file|mimes:pdf|min:10']); + $this->assertTrue($v->fails()); + $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); + } + + public function testValidateInArray() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => [1, 2, 3], 'bar' => [1, 2]], ['foo.*' => 'in_array:bar.*']); + $this->assertFalse($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => [1, 2], 'bar' => [1, 2, 3]], ['foo.*' => 'in_array:bar.*']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => [['bar_id' => 5], ['bar_id' => 2]], 'bar' => [['id' => 1, ['id' => 2]]]], ['foo.*.bar_id' => 'in_array:bar.*.id']); + $this->assertFalse($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => [['bar_id' => 1], ['bar_id' => 2]], 'bar' => [['id' => 1, ['id' => 2]]]], ['foo.*.bar_id' => 'in_array:bar.*.id']); + $this->assertTrue($v->passes()); + + $trans->addLines(['validation.in_array' => 'The value of :attribute does not exist in :other.'], 'en'); + $v = new Validator($trans, ['foo' => [1, 2, 3], 'bar' => [1, 2]], ['foo.*' => 'in_array:bar.*']); + $this->assertSame('The value of foo.2 does not exist in bar.*.', $v->messages()->first('foo.2')); + } + + public function testValidateConfirmed() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['password' => 'foo'], ['password' => 'Confirmed']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['password' => 'foo', 'password_confirmation' => 'bar'], ['password' => 'Confirmed']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['password' => 'foo', 'password_confirmation' => 'foo'], ['password' => 'Confirmed']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['password' => '1e2', 'password_confirmation' => '100'], ['password' => 'Confirmed']); + $this->assertFalse($v->passes()); + } + + public function testValidateSame() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 'bar', 'baz' => 'boom'], ['foo' => 'Same:baz']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'bar'], ['foo' => 'Same:baz']); + $this->assertFalse($v->passes()); $v = new Validator($trans, ['foo' => 'bar', 'baz' => 'bar'], ['foo' => 'Same:baz']); $this->assertTrue($v->passes()); @@ -1210,7 +1626,7 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->passes()); $v = new Validator($trans, ['foo' => 'bar'], ['foo' => 'Different:baz']); - $this->assertFalse($v->passes()); + $this->assertTrue($v->passes()); $v = new Validator($trans, ['foo' => 'bar', 'baz' => 'bar'], ['foo' => 'Different:baz']); $this->assertFalse($v->passes()); @@ -1222,7 +1638,7 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->passes()); $v = new Validator($trans, ['foo' => 'bar', 'baz' => 'boom'], ['foo' => 'Different:fuu,baz']); - $this->assertFalse($v->passes()); + $this->assertTrue($v->passes()); $v = new Validator($trans, ['foo' => 'bar', 'fuu' => 'bar', 'baz' => 'boom'], ['foo' => 'Different:fuu,baz']); $this->assertFalse($v->passes()); @@ -1249,6 +1665,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['lhs' => 15.0], ['lhs' => 'numeric|gt:10']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['lhs' => 5, 10 => 1], ['lhs' => 'numeric|gt:10']); + $this->assertTrue($v->fails()); + $v = new Validator($trans, ['lhs' => '15'], ['lhs' => 'numeric|gt:10']); $this->assertTrue($v->passes()); @@ -1258,9 +1677,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['lhs' => ['string'], 'rhs' => [1, 'string']], ['lhs' => 'gt:rhs']); $this->assertTrue($v->fails()); - $fileOne = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileOne = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileOne->expects($this->any())->method('getSize')->willReturn(5472); - $fileTwo = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileTwo = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileTwo->expects($this->any())->method('getSize')->willReturn(3151); $v = new Validator($trans, ['lhs' => $fileOne, 'rhs' => $fileTwo], ['lhs' => 'gt:rhs']); $this->assertTrue($v->passes()); @@ -1299,9 +1718,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['lhs' => ['string'], 'rhs' => [1, 'string']], ['lhs' => 'lt:rhs']); $this->assertTrue($v->passes()); - $fileOne = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileOne = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileOne->expects($this->any())->method('getSize')->willReturn(5472); - $fileTwo = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileTwo = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileTwo->expects($this->any())->method('getSize')->willReturn(3151); $v = new Validator($trans, ['lhs' => $fileOne, 'rhs' => $fileTwo], ['lhs' => 'lt:rhs']); $this->assertTrue($v->fails()); @@ -1340,9 +1759,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['lhs' => ['string'], 'rhs' => [1, 'string']], ['lhs' => 'gte:rhs']); $this->assertTrue($v->fails()); - $fileOne = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileOne = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileOne->expects($this->any())->method('getSize')->willReturn(5472); - $fileTwo = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileTwo = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileTwo->expects($this->any())->method('getSize')->willReturn(5472); $v = new Validator($trans, ['lhs' => $fileOne, 'rhs' => $fileTwo], ['lhs' => 'gte:rhs']); $this->assertTrue($v->passes()); @@ -1381,9 +1800,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['lhs' => ['string'], 'rhs' => [1, 'string']], ['lhs' => 'lte:rhs']); $this->assertTrue($v->passes()); - $fileOne = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileOne = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileOne->expects($this->any())->method('getSize')->willReturn(5472); - $fileTwo = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $fileTwo = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $fileTwo->expects($this->any())->method('getSize')->willReturn(5472); $v = new Validator($trans, ['lhs' => $fileOne, 'rhs' => $fileTwo], ['lhs' => 'lte:rhs']); $this->assertTrue($v->passes()); @@ -1398,6 +1817,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => 'no'], ['foo' => 'Accepted']); $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => 'off'], ['foo' => 'Accepted']); + $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => null], ['foo' => 'Accepted']); $this->assertFalse($v->passes()); @@ -1407,6 +1829,9 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => 0], ['foo' => 'Accepted']); $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => '0'], ['foo' => 'Accepted']); + $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => false], ['foo' => 'Accepted']); $this->assertFalse($v->passes()); @@ -1432,6 +1857,200 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->passes()); } + public function testValidateAcceptedIf() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 'no', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'off', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => null, 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 0, 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => '0', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => false, 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'false', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'yes', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'on', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '1', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 1, 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => true, 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'true', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertTrue($v->passes()); + + // accepted_if:bar,aaa + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.accepted_if' => 'The :attribute field must be accepted when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'no', 'bar' => 'aaa'], ['foo' => 'accepted_if:bar,aaa']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be accepted when bar is aaa.', $v->messages()->first('foo')); + + // accepted_if:bar,aaa,... + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.accepted_if' => 'The :attribute field must be accepted when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'no', 'bar' => 'abc'], ['foo' => 'accepted_if:bar,aaa,bbb,abc']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be accepted when bar is abc.', $v->messages()->first('foo')); + + // accepted_if:bar,boolean + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.accepted_if' => 'The :attribute field must be accepted when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'no', 'bar' => false], ['foo' => 'accepted_if:bar,false']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be accepted when bar is false.', $v->messages()->first('foo')); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.accepted_if' => 'The :attribute field must be accepted when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'no', 'bar' => true], ['foo' => 'accepted_if:bar,true']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be accepted when bar is true.', $v->messages()->first('foo')); + } + + public function testValidateDeclined() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 'yes'], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'on'], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => null], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, [], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 1], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => '1'], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => true], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'true'], ['foo' => 'Declined']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'no'], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'off'], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '0'], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 0], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => false], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'false'], ['foo' => 'Declined']); + $this->assertTrue($v->passes()); + } + + public function testValidateDeclinedIf() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 'yes', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'on', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => null, 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 1, 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => '1', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => true, 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'true', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'no', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'off', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 0, 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '0', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => false, 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => 'false', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertTrue($v->passes()); + + // declined_if:bar,aaa + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.declined_if' => 'The :attribute field must be declined when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'yes', 'bar' => 'aaa'], ['foo' => 'declined_if:bar,aaa']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be declined when bar is aaa.', $v->messages()->first('foo')); + + // declined_if:bar,aaa,... + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.declined_if' => 'The :attribute field must be declined when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'yes', 'bar' => 'abc'], ['foo' => 'declined_if:bar,aaa,bbb,abc']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be declined when bar is abc.', $v->messages()->first('foo')); + + // declined_if:bar,boolean + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.declined_if' => 'The :attribute field must be declined when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'yes', 'bar' => false], ['foo' => 'declined_if:bar,false']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be declined when bar is false.', $v->messages()->first('foo')); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.declined_if' => 'The :attribute field must be declined when :other is :value.'], 'en'); + $v = new Validator($trans, ['foo' => 'yes', 'bar' => true], ['foo' => 'declined_if:bar,true']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('The foo field must be declined when bar is true.', $v->messages()->first('foo')); + } + public function testValidateEndsWith() { $trans = $this->getIlluminateArrayTranslator(); @@ -1688,12 +2307,12 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array|Size:4']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(3072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Size:3']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Size:3']); $this->assertFalse($v->passes()); @@ -1726,12 +2345,12 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array|Between:1,2']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(3072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Between:1,5']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Between:1,2']); $this->assertFalse($v->passes()); @@ -1758,12 +2377,12 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => [1, 2]], ['foo' => 'Array|Min:3']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(3072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Min:2']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(File::class)->setMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(File::class)->onlyMethods(['getSize'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Min:10']); $this->assertFalse($v->passes()); @@ -1790,24 +2409,112 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array|Max:2']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['isValid', 'getSize'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['isValid', 'getSize'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); $file->method('isValid')->willReturn(true); $file->method('getSize')->willReturn(3072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['isValid', 'getSize'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['isValid', 'getSize'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); $file->method('isValid')->willReturn(true); $file->method('getSize')->willReturn(4072); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:2']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['isValid'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['isValid'])->setConstructorArgs([__FILE__, basename(__FILE__)])->getMock(); $file->expects($this->any())->method('isValid')->willReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); $this->assertFalse($v->passes()); } + /** + * @param mixed $input + * @param mixed $allowed + * @param bool $passes + * + * @dataProvider multipleOfDataProvider + */ + public function testValidateMutlpleOf($input, $allowed, $passes) + { + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.multiple_of' => 'The :attribute must be a multiple of :value'], 'en'); + + $v = new Validator($trans, ['foo' => $input], ['foo' => "multiple_of:{$allowed}"]); + + $this->assertSame($passes, $v->passes()); + if ($v->fails()) { + $this->assertSame("The foo must be a multiple of {$allowed}", $v->messages()->first('foo')); + } else { + $this->assertSame('', $v->messages()->first('foo')); + } + } + + public function multipleOfDataProvider() + { + return [ + [0, 0, false], // zero (same) + [0, 10, true], // zero + integer + [10, 0, false], + [0, 10.1, true], // zero + float + [10.1, 0, false], + [0, -10, true], // zero + -integer + [-10, 0, false], + [0, -10.1, true], // zero + -float + [-10.1, 0, false], + [10, 10, true], // integer (same) + [10, 5, true], // integer + integer + [10, 4, false], + [20, 10, true], + [5, 10, false], + [10, -5, true], // integer + -integer + [10, -4, false], + [-20, 10, true], + [-5, 10, false], + [-10, -10, true], // -integer (same) + [-10, -5, true], // -integer + -integer + [-10, -4, false], + [-20, -10, true], + [-5, -10, false], + [10, 10.0, true], // integer + float (same) + [10, 5.0, true], // integer + float + [10, 4.0, false], + [20.0, 10, true], + [5.0, 10, false], + [10.0, -10.0, true], // integer + -float (same) + [10, -5.0, true], // integer + -float + [10, -4.0, false], + [-20.0, 10, true], + [-5.0, 10, false], + [10.0, -10.0, true], // -integer + float (same) + [-10, 5.0, true], // -integer + float + [-10, 4.0, false], + [20.0, -10, true], + [5.0, -10, false], + [10.5, 10.5, true], // float (same) + [10.5, 0.5, true], // float + float + [10.5, 0.3, true], // 10.5/.3 = 35, tricky for floating point division + [31.5, 10.5, true], + [31.6, 10.5, false], + [10.5, -0.5, true], // float + -float + [10.5, -0.3, true], // 10.5/.3 = 35, tricky for floating point division + [-31.5, 10.5, true], + [-31.6, 10.5, false], + [-10.5, -10.5, true], // -float (same) + [-10.5, -0.5, true], // -float + -float + [-10.5, -0.3, true], // 10.5/.3 = 35, tricky for floating point division + [-31.5, -10.5, true], + [-31.6, -10.5, false], + [2, .1, true], // fmod does this "wrong", it should be 0, but fmod(2, .1) = .1 + [.75, .05, true], // fmod does this "wrong", it should be 0, but fmod(.75, .05) = .05 + [.9, .3, true], // .9/.3 = 3, tricky for floating point division + ['foo', 1, false], // invalid values + [1, 'foo', false], + ['foo', 'foo', false], + [1, '', false], + [1, null, false], + ]; + } + public function testProperMessagesAreReturnedForSizes() { $trans = $this->getIlluminateArrayTranslator(); @@ -1822,7 +2529,7 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('string', $v->messages()->first('name')); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $file->expects($this->any())->method('isValid')->willReturn(true); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']); @@ -1858,10 +2565,10 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('minimum value', $v->messages()->first('max')); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $file->expects($this->any())->method('isValid')->willReturn(true); - $biggerFile = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $biggerFile = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $biggerFile->expects($this->any())->method('getSize')->willReturn(5120); $biggerFile->expects($this->any())->method('isValid')->willReturn(true); $v = new Validator($trans, ['photo' => $file, 'bigger' => $biggerFile], ['photo' => 'file|gt:bigger']); @@ -1900,10 +2607,10 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('maximum value', $v->messages()->first('min')); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $file->expects($this->any())->method('isValid')->willReturn(true); - $smallerFile = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $smallerFile = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $smallerFile->expects($this->any())->method('getSize')->willReturn(2048); $smallerFile->expects($this->any())->method('isValid')->willReturn(true); $v = new Validator($trans, ['photo' => $file, 'smaller' => $smallerFile], ['photo' => 'file|lt:smaller']); @@ -1942,10 +2649,10 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('minimum value', $v->messages()->first('max')); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $file->expects($this->any())->method('isValid')->willReturn(true); - $biggerFile = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $biggerFile = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $biggerFile->expects($this->any())->method('getSize')->willReturn(5120); $biggerFile->expects($this->any())->method('isValid')->willReturn(true); $v = new Validator($trans, ['photo' => $file, 'bigger' => $biggerFile], ['photo' => 'file|gte:bigger']); @@ -1984,10 +2691,10 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('maximum value', $v->messages()->first('min')); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $file->expects($this->any())->method('getSize')->willReturn(4072); $file->expects($this->any())->method('isValid')->willReturn(true); - $smallerFile = $this->getMockBuilder(UploadedFile::class)->setMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); + $smallerFile = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getSize', 'isValid'])->setConstructorArgs([__FILE__, false])->getMock(); $smallerFile->expects($this->any())->method('getSize')->willReturn(2048); $smallerFile->expects($this->any())->method('isValid')->willReturn(true); $v = new Validator($trans, ['photo' => $file, 'smaller' => $smallerFile], ['photo' => 'file|lte:smaller']); @@ -2118,6 +2825,15 @@ class ValidationValidatorTest extends TestCase $v->messages()->setFormat(':message'); $this->assertSame('There is a duplication!', $v->messages()->first('foo.0')); $this->assertSame('There is a duplication!', $v->messages()->first('foo.1')); + + $v = new Validator($trans, ['foo' => ['0100', '100']], ['foo.*' => 'distinct'], ['foo.*.distinct' => 'There is a duplication!']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('There is a duplication!', $v->messages()->first('foo.0')); + $this->assertSame('There is a duplication!', $v->messages()->first('foo.1')); + + $v = new Validator($trans, ['foo' => ['0100', '100']], ['foo.*' => 'distinct:strict']); + $this->assertTrue($v->passes()); } public function testValidateDistinctForTopLevelArrays() @@ -2147,42 +2863,42 @@ class ValidationValidatorTest extends TestCase { $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Unique:users']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', null, null, [])->andReturn(0); $v->setPresenceVerifier($mock); $this->assertTrue($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Unique:connection.users']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with('connection'); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', null, null, [])->andReturn(0); $v->setPresenceVerifier($mock); $this->assertTrue($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Unique:users,email_addr,1']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email_addr', 'foo', '1', 'id', [])->andReturn(1); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Unique:users,email_addr,1,id_col']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email_addr', 'foo', '1', 'id_col', [])->andReturn(2); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $v = new Validator($trans, ['users' => [['id' => 1, 'email' => 'foo']]], ['users.*.email' => 'Unique:users,email,[users.*.id]']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', '1', 'id', [])->andReturn(1); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Unique:users,email_addr,NULL,id_col,foo,bar']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->withArgs(function () { return func_get_args() === ['users', 'email_addr', 'foo', null, 'id_col', ['foo' => 'bar']]; @@ -2197,7 +2913,7 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, [['email' => 'foo', 'type' => 'bar']], [ '*.email' => 'unique:users', '*.type' => 'exists:user_types', ]); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->twice()->with(null); $mock->shouldReceive('getCount')->with('users', 'email', 'foo', null, null, [])->andReturn(0); $mock->shouldReceive('getCount')->with('user_types', 'type', 'bar', null, null, [])->andReturn(1); @@ -2212,7 +2928,7 @@ class ValidationValidatorTest extends TestCase '*.email' => (new Unique('users'))->where($closure), '*.type' => (new Exists('user_types'))->where($closure), ]); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->twice()->with(null); $mock->shouldReceive('getCount')->with('users', 'email', 'foo', null, 'id', [$closure])->andReturn(0); $mock->shouldReceive('getCount')->with('user_types', 'type', 'bar', null, null, [$closure])->andReturn(1); @@ -2224,7 +2940,7 @@ class ValidationValidatorTest extends TestCase { $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Exists:users']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', null, null, [])->andReturn(1); $v->setPresenceVerifier($mock); @@ -2232,35 +2948,35 @@ class ValidationValidatorTest extends TestCase $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Exists:users,email,account_id,1,name,taylor']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', null, null, ['account_id' => 1, 'name' => 'taylor'])->andReturn(1); $v->setPresenceVerifier($mock); $this->assertTrue($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Exists:users,email_addr']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'email_addr', 'foo', null, null, [])->andReturn(0); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $v = new Validator($trans, ['email' => ['foo']], ['email' => 'Exists:users,email_addr']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getMultiCount')->once()->with('users', 'email_addr', ['foo'], [])->andReturn(0); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $v = new Validator($trans, ['email' => 'foo'], ['email' => 'Exists:connection.users']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with('connection'); $mock->shouldReceive('getCount')->once()->with('users', 'email', 'foo', null, null, [])->andReturn(1); $v->setPresenceVerifier($mock); $this->assertTrue($v->passes()); $v = new Validator($trans, ['email' => ['foo', 'foo']], ['email' => 'exists:users,email_addr']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getMultiCount')->once()->with('users', 'email_addr', ['foo', 'foo'], [])->andReturn(1); $v->setPresenceVerifier($mock); @@ -2271,14 +2987,14 @@ class ValidationValidatorTest extends TestCase { $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['id' => 'foo'], ['id' => 'Integer|Exists:users,id']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('getCount')->never(); $v->setPresenceVerifier($mock); $this->assertFalse($v->passes()); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['id' => '1'], ['id' => 'Integer|Exists:users,id']); - $mock = m::mock(PresenceVerifierInterface::class); + $mock = m::mock(DatabasePresenceVerifierInterface::class); $mock->shouldReceive('setConnection')->once()->with(null); $mock->shouldReceive('getCount')->once()->with('users', 'id', '1', null, null, [])->andReturn(1); $v->setPresenceVerifier($mock); @@ -2307,6 +3023,49 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->fails()); } + public function testValidateMacAddress() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => 'foo'], ['mac' => 'mac_address']); + $this->assertFalse($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01-23-45-67-89-ab'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01-23-45-67-89-AB'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01-23-45-67-89-aB'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01:23:45:67:89:ab'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01:23:45:67:89:AB'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01:23:45:67:89:aB'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '01:23:45-67:89:aB'], ['mac' => 'mac_address']); + $this->assertFalse($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => 'xx:23:45:67:89:aB'], ['mac' => 'mac_address']); + $this->assertFalse($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['mac' => '0123.4567.89ab'], ['mac' => 'mac_address']); + $this->assertTrue($v->passes()); + } + public function testValidateEmail() { $trans = $this->getIlluminateArrayTranslator(); @@ -2317,7 +3076,8 @@ class ValidationValidatorTest extends TestCase $this->assertFalse($v->passes()); $v = new Validator($trans, [ - 'x' => new class { + 'x' => new class + { public function __toString() { return 'aslsdlks'; @@ -2327,7 +3087,8 @@ class ValidationValidatorTest extends TestCase $this->assertFalse($v->passes()); $v = new Validator($trans, [ - 'x' => new class { + 'x' => new class + { public function __toString() { return 'foo@gmail.com'; @@ -2356,6 +3117,43 @@ class ValidationValidatorTest extends TestCase { $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'foo@bar'], ['x' => 'email:filter']); $this->assertFalse($v->passes()); + + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'example@example.com'], ['x' => 'email:filter']); + $this->assertTrue($v->passes()); + + // Unicode characters are not allowed + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'exämple@example.com'], ['x' => 'email:filter']); + $this->assertFalse($v->passes()); + + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'exämple@exämple.com'], ['x' => 'email:filter']); + $this->assertFalse($v->passes()); + } + + public function testValidateEmailWithFilterUnicodeCheck() + { + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'foo@bar'], ['x' => 'email:filter_unicode']); + $this->assertFalse($v->passes()); + + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'example@example.com'], ['x' => 'email:filter_unicode']); + $this->assertTrue($v->passes()); + + // Any unicode characters are allowed only in local-part + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'exämple@example.com'], ['x' => 'email:filter_unicode']); + $this->assertTrue($v->passes()); + + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'exämple@exämple.com'], ['x' => 'email:filter_unicode']); + $this->assertFalse($v->passes()); + } + + public function testValidateEmailWithCustomClassCheck() + { + $container = m::mock(Container::class); + $container->shouldReceive('make')->with(NoRFCWarningsValidation::class)->andReturn(new NoRFCWarningsValidation); + + $v = new Validator($this->getIlluminateArrayTranslator(), ['x' => 'foo@bar '], ['x' => 'email:'.NoRFCWarningsValidation::class]); + $v->setContainer($container); + + $this->assertFalse($v->passes()); } /** @@ -2664,63 +3462,76 @@ class ValidationValidatorTest extends TestCase public function testValidateImage() { $trans = $this->getIlluminateArrayTranslator(); - $uploadedFile = [__FILE__, '', null, null, null, true]; + $uploadedFile = [__FILE__, '', null, null, true]; - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('php'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('php'); $v = new Validator($trans, ['x' => $file], ['x' => 'image']); $this->assertFalse($v->passes()); - $file2 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); - $file2->expects($this->any())->method('guessExtension')->willReturn('jpg'); + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2->expects($this->any())->method('guessExtension')->willReturn('jpeg'); $file2->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpeg'); $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); $this->assertTrue($v->passes()); - $file2 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file2->expects($this->any())->method('guessExtension')->willReturn('jpg'); $file2->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpg'); $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); $this->assertTrue($v->passes()); - $file3 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2->expects($this->any())->method('guessExtension')->willReturn('jpg'); + $file2->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpg'); + $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); + $this->assertTrue($v->passes()); + + $file3 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file3->expects($this->any())->method('guessExtension')->willReturn('gif'); $file3->expects($this->any())->method('getClientOriginalExtension')->willReturn('gif'); $v = new Validator($trans, ['x' => $file3], ['x' => 'image']); $this->assertTrue($v->passes()); - $file4 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file4 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file4->expects($this->any())->method('guessExtension')->willReturn('bmp'); $file4->expects($this->any())->method('getClientOriginalExtension')->willReturn('bmp'); $v = new Validator($trans, ['x' => $file4], ['x' => 'image']); $this->assertTrue($v->passes()); - $file5 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file5 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file5->expects($this->any())->method('guessExtension')->willReturn('png'); $file5->expects($this->any())->method('getClientOriginalExtension')->willReturn('png'); $v = new Validator($trans, ['x' => $file5], ['x' => 'image']); $this->assertTrue($v->passes()); - $file6 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file6 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file6->expects($this->any())->method('guessExtension')->willReturn('svg'); $file6->expects($this->any())->method('getClientOriginalExtension')->willReturn('svg'); $v = new Validator($trans, ['x' => $file6], ['x' => 'image']); $this->assertTrue($v->passes()); - $file7 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file7 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file7->expects($this->any())->method('guessExtension')->willReturn('webp'); $file7->expects($this->any())->method('getClientOriginalExtension')->willReturn('webp'); - $v = new Validator($trans, ['x' => $file7], ['x' => 'image']); + + $v = new Validator($trans, ['x' => $file7], ['x' => 'Image']); + $this->assertTrue($v->passes()); + + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2->expects($this->any())->method('guessExtension')->willReturn('jpg'); + $file2->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpg'); + $v = new Validator($trans, ['x' => $file2], ['x' => 'Image']); $this->assertTrue($v->passes()); } public function testValidateImageDoesNotAllowPhpExtensionsOnImageMime() { $trans = $this->getIlluminateArrayTranslator(); - $uploadedFile = [__FILE__, '', null, null, null, true]; + $uploadedFile = [__FILE__, '', null, null, true]; - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('jpeg'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('php'); $v = new Validator($trans, ['x' => $file], ['x' => 'image']); @@ -2730,7 +3541,7 @@ class ValidationValidatorTest extends TestCase public function testValidateImageDimensions() { // Knowing that demo image.png has width = 3 and height = 2 - $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image.png', '', null, null, null, true); + $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image.png', '', null, null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => 'file'], ['x' => 'dimensions']); @@ -2779,7 +3590,7 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->fails()); // Knowing that demo image2.png has width = 4 and height = 2 - $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image2.png', '', null, null, null, true); + $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image2.png', '', null, null, true); $trans = $this->getIlluminateArrayTranslator(); // Ensure validation doesn't erroneously fail when ratio has no fractional part @@ -2787,41 +3598,41 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->passes()); // This test fails without suppressing warnings on getimagesize() due to a read error. - $emptyUploadedFile = new UploadedFile(__DIR__.'/fixtures/empty.png', '', null, null, null, true); + $emptyUploadedFile = new UploadedFile(__DIR__.'/fixtures/empty.png', '', null, null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => $emptyUploadedFile], ['x' => 'dimensions:min_width=1']); $this->assertTrue($v->fails()); // Knowing that demo image3.png has width = 7 and height = 10 - $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image3.png', '', null, null, null, true); + $uploadedFile = new UploadedFile(__DIR__.'/fixtures/image3.png', '', null, null, true); $trans = $this->getIlluminateArrayTranslator(); // Ensure validation doesn't erroneously fail when ratio has no fractional part $v = new Validator($trans, ['x' => $uploadedFile], ['x' => 'dimensions:ratio=2/3']); $this->assertTrue($v->passes()); - // Ensure svg images always pass as size is irreleveant (image/svg+xml) - $svgXmlUploadedFile = new UploadedFile(__DIR__.'/fixtures/image.svg', '', 'image/svg+xml', null, null, true); + // Ensure svg images always pass as size is irrelevant (image/svg+xml) + $svgXmlUploadedFile = new UploadedFile(__DIR__.'/fixtures/image.svg', '', 'image/svg+xml', null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => $svgXmlUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgXmlFile = new UploadedFile(__DIR__.'/fixtures/image.svg', '', 'image/svg+xml', null, null, true); + $svgXmlFile = new File(__DIR__.'/fixtures/image.svg', '', 'image/svg+xml', null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => $svgXmlFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - // Ensure svg images always pass as size is irreleveant (image/svg) - $svgUploadedFile = new UploadedFile(__DIR__.'/fixtures/image2.svg', '', 'image/svg', null, null, true); + // Ensure svg images always pass as size is irrelevant (image/svg) + $svgUploadedFile = new UploadedFile(__DIR__.'/fixtures/image2.svg', '', 'image/svg', null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => $svgUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgFile = new UploadedFile(__DIR__.'/fixtures/image2.svg', '', 'image/svg', null, null, true); + $svgFile = new File(__DIR__.'/fixtures/image2.svg', '', 'image/svg', null, true); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => $svgFile], ['x' => 'dimensions:max_width=1,max_height=1']); @@ -2839,19 +3650,24 @@ class ValidationValidatorTest extends TestCase public function testValidateMimetypes() { $trans = $this->getIlluminateArrayTranslator(); - $uploadedFile = [__FILE__, '', null, null, null, true]; - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); + $uploadedFile = [__DIR__.'/ValidationRuleTest.php', '', null, null, true]; + + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file->expects($this->any())->method('guessExtension')->willReturn('rtf'); + $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('rtf'); + + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('getMimeType')->willReturn('text/rtf'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:text/*']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('getMimeType')->willReturn('application/pdf'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:text/rtf']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['getMimeType'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('getMimeType')->willReturn('image/jpeg'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:image/jpeg']); $this->assertTrue($v->passes()); @@ -2860,27 +3676,27 @@ class ValidationValidatorTest extends TestCase public function testValidateMime() { $trans = $this->getIlluminateArrayTranslator(); - $uploadedFile = [__FILE__, '', null, null, null, true]; + $uploadedFile = [__FILE__, '', null, null, true]; - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('pdf'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('pdf'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:pdf']); $this->assertTrue($v->passes()); - $file2 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'isValid'])->setConstructorArgs($uploadedFile)->getMock(); + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'isValid'])->setConstructorArgs($uploadedFile)->getMock(); $file2->expects($this->any())->method('guessExtension')->willReturn('pdf'); $file2->expects($this->any())->method('isValid')->willReturn(false); $v = new Validator($trans, ['x' => $file2], ['x' => 'mimes:pdf']); $this->assertFalse($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('jpg'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpg'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:jpeg']); $this->assertTrue($v->passes()); - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('jpg'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('jpeg'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:jpg']); @@ -2890,15 +3706,15 @@ class ValidationValidatorTest extends TestCase public function testValidateMimeEnforcesPhpCheck() { $trans = $this->getIlluminateArrayTranslator(); - $uploadedFile = [__FILE__, '', null, null, null, true]; + $uploadedFile = [__FILE__, '', null, null, true]; - $file = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file->expects($this->any())->method('guessExtension')->willReturn('pdf'); $file->expects($this->any())->method('getClientOriginalExtension')->willReturn('php'); $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:pdf']); $this->assertFalse($v->passes()); - $file2 = $this->getMockBuilder(UploadedFile::class)->setMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); + $file2 = $this->getMockBuilder(UploadedFile::class)->onlyMethods(['guessExtension', 'getClientOriginalExtension'])->setConstructorArgs($uploadedFile)->getMock(); $file2->expects($this->any())->method('guessExtension')->willReturn('php'); $file2->expects($this->any())->method('getClientOriginalExtension')->willReturn('php'); $v = new Validator($trans, ['x' => $file2], ['x' => 'mimes:pdf,php']); @@ -2911,7 +3727,7 @@ class ValidationValidatorTest extends TestCase public function testValidateFile() { $trans = $this->getIlluminateArrayTranslator(); - $file = new UploadedFile(__FILE__, '', null, null, null, true); + $file = new UploadedFile(__FILE__, '', null, null, true); $v = new Validator($trans, ['x' => '1'], ['x' => 'file']); $this->assertTrue($v->fails()); @@ -3035,8 +3851,14 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['foo' => 'Africa/Windhoek'], ['foo' => 'Timezone']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => 'africa/windhoek'], ['foo' => 'Timezone']); + $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => 'GMT'], ['foo' => 'Timezone']); - $this->assertTrue($v->passes()); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['foo' => 'GB'], ['foo' => 'Timezone']); + $this->assertFalse($v->passes()); $v = new Validator($trans, ['foo' => ['this_is_not_a_timezone']], ['foo' => 'Timezone']); $this->assertFalse($v->passes()); @@ -3138,6 +3960,12 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['x' => '2000-01-01 17:43:59'], ['x' => 'date_format:H:i:s']); $this->assertTrue($v->fails()); + $v = new Validator($trans, ['x' => '2000-01-01 17:43:59'], ['x' => 'date_format:Y-m-d H:i:s,H:i:s']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => '17:43:59'], ['x' => 'date_format:Y-m-d H:i:s,H:i:s']); + $this->assertTrue($v->passes()); + $v = new Validator($trans, ['x' => '17:43:59'], ['x' => 'date_format:H:i:s']); $this->assertTrue($v->passes()); @@ -3385,6 +4213,18 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['start' => 'invalid', 'ends' => 'invalid'], ['start' => 'date_format:d/m/Y|before:ends', 'ends' => 'date_format:d/m/Y|after:start']); $this->assertTrue($v->fails()); + $v = new Validator($trans, ['start' => '31/12/2012', 'ends' => null], ['start' => 'date_format:d/m/Y|before:ends', 'ends' => 'date_format:d/m/Y|after:start']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['start' => '31/12/2012', 'ends' => null], ['start' => 'date_format:d/m/Y|before:ends', 'ends' => 'nullable|date_format:d/m/Y|after:start']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['start' => '31/12/2012', 'ends' => null], ['start' => 'before:ends']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['start' => '31/12/2012', 'ends' => null], ['start' => 'before:ends', 'ends' => 'nullable']); + $this->assertTrue($v->fails()); + $v = new Validator($trans, ['x' => date('d/m/Y')], ['x' => 'date_format:d/m/Y|after:yesterday|before:tomorrow']); $this->assertTrue($v->passes()); @@ -3515,6 +4355,27 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, ['x' => '17:44'], ['x' => 'date_format:H:i|after_or_equal:17:45']); $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => '2012-01-14', 'bar' => '2012-01-15'], ['foo' => 'before_or_equal:bar']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '2012-01-15', 'bar' => '2012-01-15'], ['foo' => 'before_or_equal:bar']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '2012-01-15 13:00', 'bar' => '2012-01-15 12:00'], ['foo' => 'before_or_equal:bar']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => '2012-01-15 11:00', 'bar' => null], ['foo' => 'date_format:Y-m-d H:i|before_or_equal:bar', 'bar' => 'date_format:Y-m-d H:i']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => '2012-01-15 11:00', 'bar' => null], ['foo' => 'date_format:Y-m-d H:i|before_or_equal:bar', 'bar' => 'date_format:Y-m-d H:i|nullable']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => '2012-01-15 11:00', 'bar' => null], ['foo' => 'before_or_equal:bar']); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => '2012-01-15 11:00', 'bar' => null], ['foo' => 'before_or_equal:bar', 'bar' => 'nullable']); + $this->assertTrue($v->fails()); } public function testSometimesAddingRules() @@ -3522,7 +4383,7 @@ class ValidationValidatorTest extends TestCase $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => 'foo'], ['x' => 'Required']); $v->sometimes('x', 'Confirmed', function ($i) { - return $i->x == 'foo'; + return $i->x === 'foo'; }); $this->assertEquals(['x' => ['Required', 'Confirmed']], $v->getRules()); @@ -3536,21 +4397,21 @@ class ValidationValidatorTest extends TestCase $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => 'foo'], ['x' => 'Required']); $v->sometimes('x', 'Confirmed', function ($i) { - return $i->x == 'bar'; + return $i->x === 'bar'; }); $this->assertEquals(['x' => ['Required']], $v->getRules()); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => 'foo'], ['x' => 'Required']); $v->sometimes('x', 'Foo|Bar', function ($i) { - return $i->x == 'foo'; + return $i->x === 'foo'; }); $this->assertEquals(['x' => ['Required', 'Foo', 'Bar']], $v->getRules()); $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['x' => 'foo'], ['x' => 'Required']); $v->sometimes('x', ['Foo', 'Bar:Baz'], function ($i) { - return $i->x == 'foo'; + return $i->x === 'foo'; }); $this->assertEquals(['x' => ['Required', 'Foo', 'Bar:Baz']], $v->getRules()); @@ -3559,7 +4420,243 @@ class ValidationValidatorTest extends TestCase $v->sometimes('foo.*.name', 'Required|String', function ($i) { return is_null($i['foo'][0]['title']); }); - $this->assertEquals(['foo.0.name' => ['Required', 'String']], $v->getRules()); + $this->assertEquals(['foo.0.name' => ['Required', 'String']], $v->getRules()); + } + + public function testItemAwareSometimesAddingRules() + { + // ['users'] -> if users is not empty it must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]], ['users.*.name'=> 'required|string']); + $v->sometimes(['users'], 'array', function ($i, $item) { + return $item !== null; + }); + $this->assertEquals(['users' => ['array'], 'users.0.name' => ['required', 'string'], 'users.1.name' => ['required', 'string']], $v->getRules()); + + // ['users'] -> if users is null no rules will be applied + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['users' => null], ['users.*.name'=> 'required|string']); + $v->sometimes(['users'], 'array', function ($i, $item) { + return (bool) $item; + }); + $this->assertEquals([], $v->getRules()); + + // ['company.users'] -> if users is not empty it must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]]], ['company.users.*.name'=> 'required|string']); + $v->sometimes(['company.users'], 'array', function ($i, $item) { + return $item->users !== null; + }); + $this->assertEquals(['company.users' => ['array'], 'company.users.0.name' => ['required', 'string'], 'company.users.1.name' => ['required', 'string']], $v->getRules()); + + // ['company.users'] -> if users is null no rules will be applied + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => null]], ['company'=> 'required', 'company.users.*.name'=> 'required|string']); + $v->sometimes(['company.users'], 'array', function ($i, $item) { + return (bool) $item->users; + }); + $this->assertEquals(['company' => ['required']], $v->getRules()); + + // ['company.*'] -> if users is not empty it must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]]], ['company.users.*.name'=> 'required|string']); + $v->sometimes(['company.*'], 'array', function ($i, $item) { + return $item !== null; + }); + $this->assertEquals(['company.users' => ['array'], 'company.users.0.name' => ['required', 'string'], 'company.users.1.name' => ['required', 'string']], $v->getRules()); + + // ['company.*'] -> if users is null no rules will be applied + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => null]], ['company'=> 'required', 'company.users.*.name'=> 'required|string']); + $v->sometimes(['company.*'], 'array', function ($i, $item) { + return (bool) $item; + }); + $this->assertEquals(['company' => ['required']], $v->getRules()); + + // ['users.*'] -> all nested array items in users must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]], ['users.*.name'=> 'required|string']); + $v->sometimes(['users.*'], 'array', function ($i, $item) { + return (bool) $item; + }); + $this->assertEquals(['users.0' => ['array'], 'users.1' => ['array'], 'users.0.name' => ['required', 'string'], 'users.1.name' => ['required', 'string']], $v->getRules()); + + // ['company.users.*'] -> all nested array items in users must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]]], ['company.users.*.name'=> 'required|string']); + $v->sometimes(['company.users.*'], 'array', function () { + return true; + }); + $this->assertEquals(['company.users.0' => ['array'], 'company.users.1' => ['array'], 'company.users.0.name' => ['required', 'string'], 'company.users.1.name' => ['required', 'string']], $v->getRules()); + + // ['company.*.*'] -> all nested array items in users must be validated as array + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['company' => ['users' => [['name' => 'Taylor'], ['name' => 'Abigail']]]], ['company.users.*.name'=> 'required|string']); + $v->sometimes(['company.*.*'], 'array', function ($i, $item) { + return true; + }); + $this->assertEquals(['company.users.0' => ['array'], 'company.users.1' => ['array'], 'company.users.0.name' => ['required', 'string'], 'company.users.1.name' => ['required', 'string']], $v->getRules()); + + // ['user.profile.value'] -> multiple true cases, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['user' => ['profile' => ['photo' => 'image.jpg', 'type' => 'email', 'value' => 'test@test.com']]], ['user.profile.*' => ['required']]); + $v->sometimes(['user.profile.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $v->sometimes('user.profile.photo', 'mimes:jpg,bmp,png', function ($i, $item) { + return $item->photo; + }); + $this->assertEquals(['user.profile.value' => ['required', 'email'], 'user.profile.photo' => ['required', 'mimes:jpg,bmp,png'], 'user.profile.type' => ['required']], $v->getRules()); + + // ['user.profile.value'] -> multiple true cases with middle wildcard, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['user' => ['profile' => ['photo' => 'image.jpg', 'type' => 'email', 'value' => 'test@test.com']]], ['user.profile.*' => ['required']]); + $v->sometimes('user.*.value', 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $v->sometimes('user.*.photo', 'mimes:jpg,bmp,png', function ($i, $item) { + return $item->photo; + }); + $this->assertEquals(['user.profile.value' => ['required', 'email'], 'user.profile.photo' => ['required', 'mimes:jpg,bmp,png'], 'user.profile.type' => ['required']], $v->getRules()); + + // ['profiles.*.value'] -> true and false cases for the same field with middle wildcard, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['profiles' => [['type' => 'email'], ['type' => 'string']]], ['profiles.*.value' => ['required']]); + $v->sometimes(['profiles.*.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $v->sometimes('profiles.*.value', 'url', function ($i, $item) { + return $item->type !== 'email'; + }); + $this->assertEquals(['profiles.0.value' => ['required', 'email'], 'profiles.1.value' => ['required', 'url']], $v->getRules()); + + // ['profiles.*.value'] -> true case with middle wildcard, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['profiles' => [['type' => 'email'], ['type' => 'string']]], ['profiles.*.value' => ['required']]); + $v->sometimes(['profiles.*.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $this->assertEquals(['profiles.0.value' => ['required', 'email'], 'profiles.1.value' => ['required']], $v->getRules()); + + // ['profiles.*.value'] -> false case with middle wildcard, the item based condition does not match and the optional validation is not added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['profiles' => [['type' => 'string'], ['type' => 'string']]], ['profiles.*.value' => ['required']]); + $v->sometimes(['profiles.*.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $this->assertEquals(['profiles.0.value' => ['required'], 'profiles.1.value' => ['required']], $v->getRules()); + + // ['users.profiles.*.value'] -> true case nested and with middle wildcard, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['users' => ['profiles' => [['type' => 'email'], ['type' => 'string']]]], ['users.profiles.*.value' => ['required']]); + $v->sometimes(['users.profiles.*.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $this->assertEquals(['users.profiles.0.value' => ['required', 'email'], 'users.profiles.1.value' => ['required']], $v->getRules()); + + // ['users.*.*.value'] -> true case nested and with double middle wildcard, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['users' => ['profiles' => [['type' => 'email'], ['type' => 'string']]]], ['users.profiles.*.value' => ['required']]); + $v->sometimes(['users.*.*.value'], 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $this->assertEquals(['users.profiles.0.value' => ['required', 'email'], 'users.profiles.1.value' => ['required']], $v->getRules()); + + // 'user.value' -> true case nested with string, the item based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['user' => ['name' => 'username', 'type' => 'email', 'value' => 'test@test.com']], ['user.*' => ['required']]); + $v->sometimes('user.value', 'email', function ($i, $item) { + return $item->type === 'email'; + }); + $this->assertEquals(['user.name' => ['required'], 'user.type' => ['required'], 'user.value' => ['required', 'email']], $v->getRules()); + + // 'user.value' -> standard true case with string, the INPUT based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['name' => 'username', 'type' => 'email', 'value' => 'test@test.com'], ['*' => ['required']]); + $v->sometimes('value', 'email', function ($i) { + return $i->type === 'email'; + }); + $this->assertEquals(['name' => ['required'], 'type' => ['required'], 'value' => ['required', 'email']], $v->getRules()); + + // ['value'] -> standard true case with array, the INPUT based condition does match and the optional validation is added + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['name' => 'username', 'type' => 'email', 'value' => 'test@test.com'], ['*' => ['required']]); + $v->sometimes(['value'], 'email', function ($i, $item) { + return $i->type === 'email'; + }); + $this->assertEquals(['name' => ['required'], 'type' => ['required'], 'value' => ['required', 'email']], $v->getRules()); + + // ['email'] -> if value is set, it will be validated as string + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['email' => 'test@test.com'], ['*' => ['required']]); + $v->sometimes(['email'], 'email', function ($i, $item) { + return $item; + }); + $this->assertEquals(['email' => ['required', 'email']], $v->getRules()); + + // ['attendee.*'] -> if attendee name is set, all other fields will be required as well + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['attendee' => ['name' => 'Taylor', 'title' => 'Creator of Laravel', 'type' => 'Developer']], ['attendee.*'=> 'string']); + $v->sometimes(['attendee.*'], 'required', function ($i, $item) { + return (bool) $item; + }); + $this->assertEquals(['attendee.name' => ['string', 'required'], 'attendee.title' => ['string', 'required'], 'attendee.type' => ['string', 'required']], $v->getRules()); + } + + public function testValidateSometimesImplicitEachWithAsterisksBeforeAndAfter() + { + $trans = $this->getIlluminateArrayTranslator(); + + $v = new Validator($trans, [ + 'foo' => [ + ['start' => '2016-04-19', 'end' => '2017-04-19'], + ], + ], []); + $v->sometimes('foo.*.start', ['before:foo.*.end'], function () { + return true; + }); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, [ + 'foo' => [ + ['start' => '2016-04-19', 'end' => '2017-04-19'], + ], + ], []); + $v->sometimes('foo.*.start', 'before:foo.*.end', function () { + return true; + }); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, [ + 'foo' => [ + ['start' => '2016-04-19', 'end' => '2017-04-19'], + ], + ], []); + $v->sometimes('foo.*.end', ['before:foo.*.start'], function () { + return true; + }); + + $this->assertTrue($v->fails()); + + $v = new Validator($trans, [ + 'foo' => [ + ['start' => '2016-04-19', 'end' => '2017-04-19'], + ], + ], []); + $v->sometimes('foo.*.end', ['after:foo.*.start'], function () { + return true; + }); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, [ + 'foo' => [ + ['start' => '2016-04-19', 'end' => '2017-04-19'], + ], + ], []); + $v->sometimes('foo.*.start', ['after:foo.*.end'], function () { + return true; + }); + $this->assertTrue($v->fails()); } public function testCustomValidators() @@ -3655,7 +4752,7 @@ class ValidationValidatorTest extends TestCase ['*.name' => 'dependent_rule:*.age'] ); $v->addDependentExtension('dependent_rule', function ($name) use ($v) { - return Arr::get($v->getData(), $name) == 'Jamie'; + return Arr::get($v->getData(), $name) === 'Jamie'; }); $this->assertTrue($v->passes()); } @@ -3835,23 +4932,46 @@ class ValidationValidatorTest extends TestCase public function testParsingArrayKeysWithDot() { $trans = $this->getIlluminateArrayTranslator(); - + // Interpreted dot fails on empty value $v = new Validator($trans, ['foo' => ['bar' => ''], 'foo.bar' => 'valid'], ['foo.bar' => 'required']); $this->assertTrue($v->fails()); - + // Escaped dot fails on empty value $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => ''], ['foo\.bar' => 'required']); $this->assertTrue($v->fails()); - + // Interpreted dot succeeds $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => 'zxc'], ['foo\.bar' => 'required']); $this->assertFalse($v->fails()); - + // Interpreted dot followed by escaped dot fails on empty value $v = new Validator($trans, ['foo' => ['bar.baz' => '']], ['foo.bar\.baz' => 'required']); $this->assertTrue($v->fails()); - + // Interpreted dot followed by escaped dot fails on empty value $v = new Validator($trans, ['foo' => [['bar.baz' => ''], ['bar.baz' => '']]], ['foo.*.bar\.baz' => 'required']); $this->assertTrue($v->fails()); } + public function testParsingArrayKeysWithDotWhenTestingExistence() + { + $trans = $this->getIlluminateArrayTranslator(); + // RequiredWith using escaped dot in a nested array + $v = new Validator($trans, ['foo' => '', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'required_with:bar.foo\.bar']); + $this->assertFalse($v->passes()); + // RequiredWithAll using escaped dot in a nested array + $v = new Validator($trans, ['foo' => '', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'required_with_all:bar.foo\.bar']); + $this->assertFalse($v->passes()); + // RequiredWithout using escaped dot in a nested array + $v = new Validator($trans, ['foo' => 'valid', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'required_without:bar.foo\.bar']); + $this->assertTrue($v->passes()); + // RequiredWithoutAll using escaped dot in a nested array + $v = new Validator($trans, ['foo' => 'valid', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'required_without_all:bar.foo\.bar']); + $this->assertTrue($v->passes()); + // Same using escaped dot in a nested array + $v = new Validator($trans, ['foo' => 'valid', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'same:bar.foo\.bar']); + $this->assertTrue($v->passes()); + // RequiredUnless using escaped dot in a nested array + $v = new Validator($trans, ['foo' => '', 'bar' => ['foo.bar' => 'valid']], ['foo' => 'required_unless:bar.foo\.bar,valid']); + $this->assertTrue($v->passes()); + } + public function testPassingSlashVulnerability() { $trans = $this->getIlluminateArrayTranslator(); @@ -3878,46 +4998,54 @@ class ValidationValidatorTest extends TestCase $this->assertTrue($v->fails()); } - public function testCoveringEmptyKeys() - { - $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => ['' => ['bar' => '']]], ['foo.*.bar' => 'required']); - $this->assertTrue($v->fails()); - } - - public function testImplicitEachWithAsterisksWithArrayValues() + public function testPlaceholdersAreReplaced() { $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'size:4']); - $this->assertFalse($v->passes()); - - $v = new Validator($trans, ['foo' => [1, 2, 3, 4]], ['foo' => 'size:4']); - $this->assertTrue($v->passes()); + $v = new Validator($trans, [ + 'matrix' => ['\\' => ['invalid'], '1\\' => ['invalid']], + ], [ + 'matrix.*.*' => 'integer', + ]); + $this->assertTrue($v->fails()); - $v = new Validator($trans, ['foo' => [1, 2, 3, 4]], ['foo.*' => 'integer', 'foo.0' => 'required']); + $v = new Validator($trans, [ + 'matrix' => ['\\' => [1], '1\\' => [1]], + ], [ + 'matrix.*.*' => 'integer', + ]); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['foo' => [['bar' => [1, 2, 3]], ['bar' => [1, 2, 3]]]], ['foo.*.bar' => 'size:4']); - $this->assertFalse($v->passes()); + $v = new Validator($trans, [ + 'foo' => ['bar' => 'valid'], 'foo.bar' => 'invalid', 'foo->bar' => 'valid', + ], [ + 'foo\.bar' => 'required|in:valid', + ]); + $this->assertTrue($v->fails()); + $this->assertArrayHasKey('foo.bar', $v->errors()->getMessages()); - $v = new Validator($trans, - ['foo' => [['bar' => [1, 2, 3]], ['bar' => [1, 2, 3]]]], ['foo.*.bar' => 'min:3']); + $v = new Validator($trans, [ + 'foo.bar' => 'valid', + ], [ + 'foo\.bar' => 'required|in:valid', + ]); $this->assertTrue($v->passes()); + $this->assertArrayHasKey('foo.bar', $v->validated()); + } - $v = new Validator($trans, - ['foo' => [['bar' => [1, 2, 3]], ['bar' => [1, 2, 3]]]], ['foo.*.bar' => 'between:3,6']); - $this->assertTrue($v->passes()); + public function testCoveringEmptyKeys() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => ['' => ['bar' => '']]], ['foo.*.bar' => 'required']); + $this->assertTrue($v->fails()); + } - $v = new Validator($trans, - ['foo' => [['name' => 'first', 'votes' => [1, 2]], ['name' => 'second', 'votes' => ['something', 2]]]], - ['foo.*.votes' => ['Required', 'Size:2']]); - $this->assertTrue($v->passes()); + public function testImplicitEachWithAsterisksWithArrayValues() + { + $trans = $this->getIlluminateArrayTranslator(); - $v = new Validator($trans, - ['foo' => [['name' => 'first', 'votes' => [1, 2, 3]], ['name' => 'second', 'votes' => ['something', 2]]]], - ['foo.*.votes' => ['Required', 'Size:2']]); - $this->assertFalse($v->passes()); + $v = new Validator($trans, ['foo' => ['bar.baz' => '']], ['foo' => 'required']); + $this->assertEquals(['foo' => ['bar.baz' => '']], $v->validated()); } public function testValidateNestedArrayWithCommonParentChildKey() @@ -4535,40 +5663,48 @@ class ValidationValidatorTest extends TestCase $v = new Validator($trans, [], []); $implicit_no_connection = $v->parseTable(ImplicitTableModel::class); - $this->assertEquals(null, $implicit_no_connection[0]); - $this->assertEquals('implicit_table_models', $implicit_no_connection[1]); + $this->assertNull($implicit_no_connection[0]); + $this->assertSame('implicit_table_models', $implicit_no_connection[1]); $explicit_no_connection = $v->parseTable(ExplicitTableModel::class); - $this->assertEquals(null, $explicit_no_connection[0]); - $this->assertEquals('explicits', $explicit_no_connection[1]); + $this->assertNull($explicit_no_connection[0]); + $this->assertSame('explicits', $explicit_no_connection[1]); + + $explicit_model_with_prefix = $v->parseTable(ExplicitPrefixedTableModel::class); + $this->assertNull($explicit_model_with_prefix[0]); + $this->assertSame('prefix.explicits', $explicit_model_with_prefix[1]); + + $explicit_table_with_connection_prefix = $v->parseTable('connection.table'); + $this->assertSame('connection', $explicit_table_with_connection_prefix[0]); + $this->assertSame('table', $explicit_table_with_connection_prefix[1]); $noneloquent_no_connection = $v->parseTable(NonEloquentModel::class); - $this->assertEquals(null, $noneloquent_no_connection[0]); + $this->assertNull($noneloquent_no_connection[0]); $this->assertEquals(NonEloquentModel::class, $noneloquent_no_connection[1]); $raw_no_connection = $v->parseTable('table'); - $this->assertEquals(null, $raw_no_connection[0]); - $this->assertEquals('table', $raw_no_connection[1]); + $this->assertNull($raw_no_connection[0]); + $this->assertSame('table', $raw_no_connection[1]); $implicit_connection = $v->parseTable('connection.'.ImplicitTableModel::class); - $this->assertEquals('connection', $implicit_connection[0]); - $this->assertEquals('implicit_table_models', $implicit_connection[1]); + $this->assertSame('connection', $implicit_connection[0]); + $this->assertSame('implicit_table_models', $implicit_connection[1]); $explicit_connection = $v->parseTable('connection.'.ExplicitTableModel::class); - $this->assertEquals('connection', $explicit_connection[0]); - $this->assertEquals('explicits', $explicit_connection[1]); + $this->assertSame('connection', $explicit_connection[0]); + $this->assertSame('explicits', $explicit_connection[1]); $explicit_model_implicit_connection = $v->parseTable(ExplicitTableAndConnectionModel::class); - $this->assertEquals('connection', $explicit_model_implicit_connection[0]); - $this->assertEquals('explicits', $explicit_model_implicit_connection[1]); + $this->assertSame('connection', $explicit_model_implicit_connection[0]); + $this->assertSame('explicits', $explicit_model_implicit_connection[1]); $noneloquent_connection = $v->parseTable('connection.'.NonEloquentModel::class); - $this->assertEquals('connection', $noneloquent_connection[0]); + $this->assertSame('connection', $noneloquent_connection[0]); $this->assertEquals(NonEloquentModel::class, $noneloquent_connection[1]); $raw_connection = $v->parseTable('connection.table'); - $this->assertEquals('connection', $raw_connection[0]); - $this->assertEquals('table', $raw_connection[1]); + $this->assertSame('connection', $raw_connection[0]); + $this->assertSame('table', $raw_connection[1]); } public function testUsingSettersWithImplicitRules() @@ -4598,10 +5734,13 @@ class ValidationValidatorTest extends TestCase '*.name' => 'required', ]); - $this->assertEquals($v->invalid(), [ - 1 => ['name' => null], - 2 => ['name' => ''], - ]); + $this->assertEquals( + [ + 1 => ['name' => null], + 2 => ['name' => ''], + ], + $v->invalid() + ); $v = new Validator($trans, [ @@ -4611,9 +5750,12 @@ class ValidationValidatorTest extends TestCase 'name' => 'required', ]); - $this->assertEquals($v->invalid(), [ - 'name' => '', - ]); + $this->assertEquals( + [ + 'name' => '', + ], + $v->invalid() + ); } public function testValidMethod() @@ -4631,10 +5773,13 @@ class ValidationValidatorTest extends TestCase '*.name' => 'required', ]); - $this->assertEquals($v->valid(), [ - 0 => ['name' => 'John'], - 3 => ['name' => 'Doe'], - ]); + $this->assertEquals( + [ + 0 => ['name' => 'John'], + 3 => ['name' => 'Doe'], + ], + $v->valid() + ); $v = new Validator($trans, [ @@ -4648,10 +5793,13 @@ class ValidationValidatorTest extends TestCase 'age' => 'required|int', ]); - $this->assertEquals($v->valid(), [ - 'name' => 'Carlos', - 'gender' => 'male', - ]); + $this->assertEquals( + [ + 'name' => 'Carlos', + 'gender' => 'male', + ], + $v->valid() + ); } public function testNestedInvalidMethod() @@ -4674,12 +5822,15 @@ class ValidationValidatorTest extends TestCase 'regex:/[A-F]{3}[0-9]{3}/', ], ]); - $this->assertEquals($v->invalid(), [ - 'testinvalid' => '', - 'records' => [ - 3 => 'ADCD23', + $this->assertEquals( + [ + 'testinvalid' => '', + 'records' => [ + 3 => 'ADCD23', + ], ], - ]); + $v->invalid() + ); } public function testMultipleFileUploads() @@ -4706,7 +5857,8 @@ class ValidationValidatorTest extends TestCase $this->getIlluminateArrayTranslator(), ['name' => 'taylor'], [ - 'name' => new class implements Rule { + 'name' => new class implements Rule + { public function passes($attribute, $value) { return $value === 'taylor'; @@ -4728,7 +5880,8 @@ class ValidationValidatorTest extends TestCase ['name' => 'adam'], [ 'name' => [ - new class implements Rule { + new class implements Rule + { public function passes($attribute, $value) { return $value === 'taylor'; @@ -4782,7 +5935,8 @@ class ValidationValidatorTest extends TestCase $this->getIlluminateArrayTranslator(), ['name' => 'taylor', 'states' => ['AR', 'TX'], 'number' => 9], [ - 'states.*' => new class implements Rule { + 'states.*' => new class implements Rule + { public function passes($attribute, $value) { return in_array($value, ['AK', 'HI']); @@ -4801,68 +5955,291 @@ class ValidationValidatorTest extends TestCase 'number' => [ 'required', 'integer', - function ($attribute, $value, $fail) { - if ($value % 4 !== 0) { - $fail(':attribute must be divisible by 4'); + function ($attribute, $value, $fail) { + if ($value % 4 !== 0) { + $fail(':attribute must be divisible by 4'); + } + }, + ], + ] + ); + + $this->assertFalse($v->passes()); + $this->assertSame('states.0 must be AR or TX', $v->errors()->get('states.0')[0]); + $this->assertSame('states.1 must be AR or TX', $v->errors()->get('states.1')[0]); + $this->assertSame('number must be divisible by 4', $v->errors()->get('number')[0]); + + // Test array of messages with failing case... + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['name' => 42], + [ + 'name' => new class implements Rule + { + public function passes($attribute, $value) + { + return $value === 'taylor'; + } + + public function message() + { + return [':attribute must be taylor', ':attribute must be a first name']; + } + }, + ] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('name must be taylor', $v->errors()->get('name')[0]); + $this->assertSame('name must be a first name', $v->errors()->get('name')[1]); + + // Test array of messages with multiple rules for one attribute case... + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['name' => 42], + [ + 'name' => [ + new class implements Rule + { + public function passes($attribute, $value) + { + return $value === 'taylor'; + } + + public function message() + { + return [':attribute must be taylor', ':attribute must be a first name']; + } + }, 'string', + ], + ] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('name must be taylor', $v->errors()->get('name')[0]); + $this->assertSame('name must be a first name', $v->errors()->get('name')[1]); + $this->assertSame('validation.string', $v->errors()->get('name')[2]); + + // Test access to the validator data + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['password' => 'foo', 'password_confirmation' => 'foo'], + [ + 'password' => [ + new class implements Rule, DataAwareRule + { + protected $data; + + public function setData($data) + { + $this->data = $data; + } + + public function passes($attribute, $value) + { + return $value === $this->data['password_confirmation']; + } + + public function message() + { + return ['The :attribute confirmation does not match.']; + } + }, 'string', + ], + ] + ); + + $this->assertTrue($v->passes()); + + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['password' => 'foo', 'password_confirmation' => 'bar'], + [ + 'password' => [ + new class implements Rule, DataAwareRule + { + protected $data; + + public function setData($data) + { + $this->data = $data; + } + + public function passes($attribute, $value) + { + return $value === $this->data['password_confirmation']; + } + + public function message() + { + return ['The :attribute confirmation does not match.']; + } + }, 'string', + ], + ] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('The password confirmation does not match.', $v->errors()->get('password')[0]); + + // Test access to the validator + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['base' => 21, 'double' => 42], + [ + 'base' => ['integer'], + 'double' => [ + 'integer', + new class implements Rule, ValidatorAwareRule + { + protected $validator; + + public function setValidator($validator) + { + $this->validator = $validator; + } + + public function passes($attribute, $value) + { + if ($this->validator->errors()->hasAny(['base', $attribute])) { + return true; + } + + return $value === 2 * $this->validator->getData()['base']; + } + + public function message() + { + return ['The :attribute must be the double of base.']; + } + }, + ], + ] + ); + + $this->assertTrue($v->passes()); + + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['base' => 21, 'double' => 10], + [ + 'base' => ['integer'], + 'double' => [ + 'integer', + new class implements Rule, ValidatorAwareRule + { + protected $validator; + + public function setValidator($validator) + { + $this->validator = $validator; + } + + public function passes($attribute, $value) + { + if ($this->validator->errors()->hasAny(['base', $attribute])) { + return true; + } + + return $value === 2 * $this->validator->getData()['base']; + } + + public function message() + { + return ['The :attribute must be the double of base.']; + } + }, + ], + ] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('The double must be the double of base.', $v->errors()->get('double')[0]); + + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['base' => 21, 'double' => 'foo'], + [ + 'base' => ['integer'], + 'double' => [ + 'integer', + new class implements Rule, ValidatorAwareRule + { + protected $validator; + + public function setValidator($validator) + { + $this->validator = $validator; + } + + public function passes($attribute, $value) + { + if ($this->validator->errors()->hasAny(['base', $attribute])) { + return true; + } + + return $value === 2 * $this->validator->getData()['base']; + } + + public function message() + { + return ['The :attribute must be the double of base.']; } }, ], ] ); - $this->assertFalse($v->passes()); - $this->assertSame('states.0 must be AR or TX', $v->errors()->get('states.0')[0]); - $this->assertSame('states.1 must be AR or TX', $v->errors()->get('states.1')[0]); - $this->assertSame('number must be divisible by 4', $v->errors()->get('number')[0]); + $this->assertTrue($v->fails()); + $this->assertCount(1, $v->errors()->get('double')); + $this->assertSame('validation.integer', $v->errors()->get('double')[0]); + } - // Test array of messages with failing case... + public function testCustomValidationObjectWithDotKeysIsCorrectlyPassedValue() + { $v = new Validator( $this->getIlluminateArrayTranslator(), - ['name' => 42], + ['foo' => ['foo.bar' => 'baz']], [ - 'name' => new class implements Rule { + 'foo' => new class implements Rule + { public function passes($attribute, $value) { - return $value === 'taylor'; + return $value === ['foo.bar' => 'baz']; } public function message() { - return [':attribute must be taylor', ':attribute must be a first name']; + return ':attribute must be baz'; } }, ] ); - $this->assertTrue($v->fails()); - $this->assertSame('name must be taylor', $v->errors()->get('name')[0]); - $this->assertSame('name must be a first name', $v->errors()->get('name')[1]); + $this->assertTrue($v->passes()); - // Test array of messages with multiple rules for one attribute case... + // Test failed attributes contains proper entries $v = new Validator( $this->getIlluminateArrayTranslator(), - ['name' => 42], + ['foo' => ['foo.bar' => 'baz']], [ - 'name' => [ - new class implements Rule { - public function passes($attribute, $value) - { - return $value === 'taylor'; - } + 'foo.foo\.bar' => new class implements Rule + { + public function passes($attribute, $value) + { + return false; + } - public function message() - { - return [':attribute must be taylor', ':attribute must be a first name']; - } - }, 'string', - ], + public function message() + { + return ':attribute must be baz'; + } + }, ] ); - $this->assertTrue($v->fails()); - $this->assertSame('name must be taylor', $v->errors()->get('name')[0]); - $this->assertSame('name must be a first name', $v->errors()->get('name')[1]); - $this->assertSame('validation.string', $v->errors()->get('name')[2]); + $this->assertFalse($v->passes()); + $this->assertIsArray($v->failed()['foo.foo.bar']); } public function testImplicitCustomValidationObjects() @@ -4872,7 +6249,8 @@ class ValidationValidatorTest extends TestCase $this->getIlluminateArrayTranslator(), ['name' => ''], [ - 'name' => $rule = new class implements ImplicitRule { + 'name' => $rule = new class implements ImplicitRule + { public $called = false; public function passes($attribute, $value) @@ -5074,6 +6452,20 @@ class ValidationValidatorTest extends TestCase 'has_appointment' => false, ], ], + [ + [ + 'has_appointment' => ['nullable', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,null', 'required', 'date'], + ], + [ + 'has_appointment' => true, + 'appointment_date' => '2021-03-08', + ], + [ + 'has_appointment' => true, + 'appointment_date' => '2021-03-08', + ], + ], [ [ 'has_appointment' => ['required', 'bool'], @@ -5375,6 +6767,114 @@ class ValidationValidatorTest extends TestCase $this->assertSame($expectedMessages, $validator->messages()->toArray()); } + public function providesPassingExcludeData() + { + return [ + [ + [ + 'has_appointment' => ['required', 'bool'], + 'appointment_date' => ['exclude'], + ], [ + 'has_appointment' => false, + 'appointment_date' => 'should be excluded', + ], [ + 'has_appointment' => false, + ], + ], + ]; + } + + /** + * @dataProvider providesPassingExcludeData + */ + public function testExclude($rules, $data, $expectedValidatedData) + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + $data, + $rules + ); + + $passes = $validator->passes(); + + if (! $passes) { + $message = sprintf("Validation unexpectedly failed:\nRules: %s\nData: %s\nValidation error: %s", + json_encode($rules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($validator->messages()->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } + + $this->assertTrue($passes, $message ?? ''); + + $this->assertSame($expectedValidatedData, $validator->validated()); + } + + public function testExcludingArrays() + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => [['name' => 'Mohamed', 'location' => 'cairo']]], + ['users' => 'array', 'users.*.name' => 'string'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => [['name' => 'Mohamed', 'location' => 'cairo']]], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => [['name' => 'Mohamed', 'location' => 'cairo']]], + ['users' => 'array', 'users.*.name' => 'string'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => [['name' => 'Mohamed']]], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['admin' => ['name' => 'Mohamed', 'location' => 'cairo'], 'users' => [['name' => 'Mohamed', 'location' => 'cairo']]], + ['admin' => 'array', 'admin.name' => 'string', 'users' => 'array', 'users.*.name' => 'string'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['admin' => ['name' => 'Mohamed'], 'users' => [['name' => 'Mohamed']]], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => [['name' => 'Mohamed', 'location' => 'cairo']]], + ['users' => 'array'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => [['name' => 'Mohamed', 'location' => 'cairo']]], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => ['mohamed', 'zain']], + ['users' => 'array', 'users.*' => 'string'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => ['mohamed', 'zain']], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => ['admins' => [['name' => 'mohamed', 'job' => 'dev']], 'unvalidated' => 'foobar']], + ['users' => 'array', 'users.admins' => 'array', 'users.admins.*.name' => 'string'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => ['admins' => [['name' => 'mohamed']]]], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['users' => [1, 2, 3]], + ['users' => 'array|max:10'] + ); + $validator->excludeUnvalidatedArrayKeys = true; + $this->assertTrue($validator->passes()); + $this->assertSame(['users' => [1, 2, 3]], $validator->validated()); + } + public function testExcludeUnless() { $validator = new Validator( @@ -5408,6 +6908,39 @@ class ValidationValidatorTest extends TestCase ); $this->assertTrue($validator->fails()); $this->assertSame(['mouse' => ['validation.required']], $validator->messages()->toArray()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['foo' => true, 'bar' => 'baz'], + ['foo' => 'nullable', 'bar' => 'exclude_unless:foo,null'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['foo' => true], $validator->validated()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['bar' => 'Hello'], ['bar' => 'exclude_unless:foo,true']); + $this->assertTrue($v->passes()); + $this->assertSame([], $v->validated()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['bar' => 'Hello'], ['bar' => 'exclude_unless:foo,null']); + $this->assertTrue($v->passes()); + $this->assertSame(['bar' => 'Hello'], $v->validated()); + } + + public function testExcludeWithout() + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['region' => 'South'], + [ + 'country' => 'exclude_without:region|nullable|required_with:region|string|min:3', + 'region' => 'exclude_without:country|nullable|required_with:country|string|min:3', + ] + ); + + $this->assertTrue($validator->fails()); + $this->assertSame(['country' => ['validation.required_with']], $validator->messages()->toArray()); } public function testExcludeValuesAreReallyRemoved() @@ -5444,6 +6977,161 @@ class ValidationValidatorTest extends TestCase $this->assertSame(['data.1.date' => ['validation.date'], 'data.*.date' => ['validation.date']], $validator->messages()->toArray()); } + public function testFailOnFirstError() + { + $trans = $this->getIlluminateArrayTranslator(); + $data = [ + 'foo' => 'bar', + 'age' => 30, + ]; + $rules = [ + 'foo' => ['required', 'string'], + 'baz' => ['required'], + 'age' => ['required', 'min:31'], + ]; + + $expectedFailOnFirstErrorDisableResult = [ + 'baz' => [ + 'validation.required', + ], + 'age' => [ + 'validation.min.string', + ], + ]; + $failOnFirstErrorDisable = new Validator($trans, $data, $rules); + $this->assertFalse($failOnFirstErrorDisable->passes()); + $this->assertEquals($expectedFailOnFirstErrorDisableResult, $failOnFirstErrorDisable->getMessageBag()->getMessages()); + + $expectedFailOnFirstErrorEnableResult = [ + 'baz' => [ + 'validation.required', + ], + ]; + $failOnFirstErrorEnable = new Validator($trans, $data, $rules, [], []); + $failOnFirstErrorEnable->stopOnFirstFailure(); + $this->assertFalse($failOnFirstErrorEnable->passes()); + $this->assertEquals($expectedFailOnFirstErrorEnableResult, $failOnFirstErrorEnable->getMessageBag()->getMessages()); + } + + public function testArrayKeysValidationPassedWhenHasKeys() + { + $trans = $this->getIlluminateArrayTranslator(); + + $data = [ + 'baz' => [ + 'foo' => 'bar', + 'fee' => 'faa', + 'laa' => 'lee', + ], + ]; + + $rules = [ + 'baz' => [ + 'array', + 'required_array_keys:foo,fee,laa', + ], + ]; + + $validator = new Validator($trans, $data, $rules, [], []); + $this->assertTrue($validator->passes()); + } + + public function testArrayKeysValidationPassedWithPartialMatch() + { + $trans = $this->getIlluminateArrayTranslator(); + + $data = [ + 'baz' => [ + 'foo' => 'bar', + 'fee' => 'faa', + 'laa' => 'lee', + ], + ]; + + $rules = [ + 'baz' => [ + 'array', + 'required_array_keys:foo,fee', + ], + ]; + + $validator = new Validator($trans, $data, $rules, [], []); + $this->assertTrue($validator->passes()); + } + + public function testArrayKeysValidationFailsWithMissingKey() + { + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.required_array_keys' => 'The :attribute field must contain entries for :values'], 'en'); + + $data = [ + 'baz' => [ + 'foo' => 'bar', + 'fee' => 'faa', + 'laa' => 'lee', + ], + ]; + + $rules = [ + 'baz' => [ + 'array', + 'required_array_keys:foo,fee,boo,bar', + ], + ]; + + $validator = new Validator($trans, $data, $rules, [], []); + $this->assertFalse($validator->passes()); + $this->assertSame( + 'The baz field must contain entries for foo, fee, boo, bar', + $validator->messages()->first('baz') + ); + } + + public function testArrayKeysValidationFailsWithNotAnArray() + { + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.required_array_keys' => 'The :attribute field must contain entries for :values'], 'en'); + + $data = [ + 'baz' => 'no an array', + ]; + + $rules = [ + 'baz' => [ + 'required_array_keys:foo,fee,boo,bar', + ], + ]; + + $validator = new Validator($trans, $data, $rules, [], []); + $this->assertFalse($validator->passes()); + $this->assertSame( + 'The baz field must contain entries for foo, fee, boo, bar', + $validator->messages()->first('baz') + ); + } + + public function testArrayKeysWithDotIntegerMin() + { + $trans = $this->getIlluminateArrayTranslator(); + + $data = [ + 'foo.bar' => -1, + ]; + + $rules = [ + 'foo\.bar' => 'integer|min:1', + ]; + + $expectedResult = [ + 'foo.bar' => [ + 'validation.min.numeric', + ], + ]; + + $validator = new Validator($trans, $data, $rules, [], []); + $this->assertEquals($expectedResult, $validator->getMessageBag()->getMessages()); + } + protected function getTranslator() { return m::mock(TranslatorContract::class); @@ -5470,6 +7158,13 @@ class ExplicitTableModel extends Model public $timestamps = false; } +class ExplicitPrefixedTableModel extends Model +{ + protected $table = 'prefix.explicits'; + protected $guarded = []; + public $timestamps = false; +} + class ExplicitTableAndConnectionModel extends Model { protected $table = 'explicits'; diff --git a/tests/View/Blade/BladeClassTest.php b/tests/View/Blade/BladeClassTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6c89f31c263588f8ae4d204293921559ca3a170e --- /dev/null +++ b/tests/View/Blade/BladeClassTest.php @@ -0,0 +1,14 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +class BladeClassTest extends AbstractBladeTestCase +{ + public function testClassesAreConditionallyCompiledFromArray() + { + $string = "<span @class(['font-bold', 'mt-4', 'ml-2' => true, 'mr-2' => false])></span>"; + $expected = "<span class=\"<?php echo \Illuminate\Support\Arr::toCssClasses(['font-bold', 'mt-4', 'ml-2' => true, 'mr-2' => false]) ?>\"></span>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeComponentTagCompilerTest.php b/tests/View/Blade/BladeComponentTagCompilerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e3a055ba8d99c0c9737548046c9f2f39edfe199f --- /dev/null +++ b/tests/View/Blade/BladeComponentTagCompilerTest.php @@ -0,0 +1,376 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +use Illuminate\Container\Container; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\View\Factory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\View\Compilers\BladeCompiler; +use Illuminate\View\Compilers\ComponentTagCompiler; +use Illuminate\View\Component; +use InvalidArgumentException; +use Mockery; + +class BladeComponentTagCompilerTest extends AbstractBladeTestCase +{ + protected function tearDown(): void + { + Mockery::close(); + } + + public function testSlotsCanBeCompiled() + { + $result = $this->compiler()->compileSlots('<x-slot name="foo"> +</x-slot>'); + + $this->assertSame("@slot('foo', null, []) \n".' @endslot', trim($result)); + } + + public function testDynamicSlotsCanBeCompiled() + { + $result = $this->compiler()->compileSlots('<x-slot :name="$foo"> +</x-slot>'); + + $this->assertSame("@slot(\$foo, null, []) \n".' @endslot', trim($result)); + } + + public function testSlotsWithAttributesCanBeCompiled() + { + $result = $this->compiler()->compileSlots('<x-slot name="foo" class="font-bold"> +</x-slot>'); + + $this->assertSame("@slot('foo', null, ['class' => 'font-bold']) \n".' @endslot', trim($result)); + } + + public function testSlotsWithDynamicAttributesCanBeCompiled() + { + $result = $this->compiler()->compileSlots('<x-slot name="foo" :class="$classes"> +</x-slot>'); + + $this->assertSame("@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\$classes)]) \n".' @endslot', trim($result)); + } + + public function testBasicComponentParsing() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<div><x-alert type="foo" limit="5" @click="foo" wire:click="changePlan(\'{{ $plan }}\')" required /><x-alert /></div>'); + + $this->assertSame("<div>##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes(['type' => 'foo','limit' => '5','@click' => 'foo','wire:click' => 'changePlan(\''.e(\$plan).'\')','required' => true]); ?>\n". +"@endComponentClass##END-COMPONENT-CLASS####BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes([]); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##</div>', trim($result)); + } + + public function testBasicComponentWithEmptyAttributesParsing() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<div><x-alert type="" limit=\'\' @click="" required /></div>'); + + $this->assertSame("<div>##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes(['type' => '','limit' => '','@click' => '','required' => true]); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##</div>', trim($result)); + } + + public function testDataCamelCasing() + { + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile user-id="1"></x-profile>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => '1']) +<?php \$component->withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonData() + { + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile :user-id="1"></x-profile>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => 1]) +<?php \$component->withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testEscapedColonAttribute() + { + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile :user-id="1" ::title="user.name"></x-profile>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => 1]) +<?php \$component->withAttributes([':title' => 'user.name']); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonAttributesIsEscapedIfStrings() + { + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile :src="\'foo\'"></x-profile>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) +<?php \$component->withAttributes(['src' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('foo')]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonNestedComponentParsing() + { + $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags('<x-foo:alert></x-foo:alert>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'foo:alert', []) +<?php \$component->withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonStartingNestedComponentParsing() + { + $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags('<x:foo:alert></x-foo:alert>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'foo:alert', []) +<?php \$component->withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiled() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<div><x-alert/></div>'); + + $this->assertSame("<div>##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes([]); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##</div>', trim($result)); + } + + public function testClassNamesCanBeGuessed() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + Container::setInstance($container); + + $result = $this->compiler()->guessClassName('alert'); + + $this->assertSame("App\View\Components\Alert", trim($result)); + + Container::setInstance(null); + } + + public function testClassNamesCanBeGuessedWithNamespaces() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + Container::setInstance($container); + + $result = $this->compiler()->guessClassName('base.alert'); + + $this->assertSame("App\View\Components\Base\Alert", trim($result)); + + Container::setInstance(null); + } + + public function testComponentsCanBeCompiledWithHyphenAttributes() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<x-alert class="bar" wire:model="foo" x-on:click="bar" @click="baz" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes(['class' => 'bar','wire:model' => 'foo','x-on:click' => 'bar','@click' => 'baz']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiledWithDataAndAttributes() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<x-alert title="foo" class="bar" wire:model="foo" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', ['title' => 'foo']) +<?php \$component->withAttributes(['class' => 'bar','wire:model' => 'foo']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testComponentCanReceiveAttributeBag() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile class="bar" {{ $attributes }} wire:model="foo"></x-profile>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) +<?php \$component->withAttributes(['class' => 'bar','attributes' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\$attributes),'wire:model' => 'foo']); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testSelfClosingComponentCanReceiveAttributeBag() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<div><x-alert title="foo" class="bar" {{ $attributes->merge([\'class\' => \'test\']) }} wire:model="foo" /></div>'); + + $this->assertSame("<div>##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', ['title' => 'foo']) +<?php \$component->withAttributes(['class' => 'bar','attributes' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\$attributes->merge(['class' => 'test'])),'wire:model' => 'foo']); ?>\n". + '@endComponentClass##END-COMPONENT-CLASS##</div>', trim($result)); + } + + public function testComponentsCanHaveAttachedWord() + { + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('<x-profile></x-profile>Words'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) +<?php \$component->withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##Words", trim($result)); + } + + public function testSelfClosingComponentsCanHaveAttachedWord() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<x-alert/>Words'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes([]); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##Words', trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiledWithBoundData() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<x-alert :title="$title" class="bar" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', ['title' => \$title]) +<?php \$component->withAttributes(['class' => 'bar']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testPairedComponentTags() + { + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('<x-alert> +</x-alert>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) +<?php \$component->withAttributes([]); ?> + @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testClasslessComponents() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + $factory->shouldReceive('exists')->andReturn(true); + Container::setInstance($container); + + $result = $this->compiler()->compileTags('<x-anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'anonymous-component', ['view' => 'components.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) +<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithIndexView() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + $factory->shouldReceive('exists')->andReturn(false, true); + Container::setInstance($container); + + $result = $this->compiler()->compileTags('<x-anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'anonymous-component', ['view' => 'components.anonymous-component.index','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) +<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testPackagesClasslessComponents() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + $factory->shouldReceive('exists')->andReturn(true); + Container::setInstance($container); + + $result = $this->compiler()->compileTags('<x-package::anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'package::anonymous-component', ['view' => 'package::components.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) +<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n". +'@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testAttributeSanitization() + { + $class = new class + { + public function __toString() + { + return '<hi>'; + } + }; + + $model = new class extends Model {}; + + $this->assertEquals(e('<hi>'), BladeCompiler::sanitizeComponentAttribute('<hi>')); + $this->assertEquals(e('1'), BladeCompiler::sanitizeComponentAttribute('1')); + $this->assertEquals(1, BladeCompiler::sanitizeComponentAttribute(1)); + $this->assertEquals(e('<hi>'), BladeCompiler::sanitizeComponentAttribute($class)); + $this->assertSame($model, BladeCompiler::sanitizeComponentAttribute($model)); + } + + public function testItThrowsAnExceptionForNonExistingAliases() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + $factory->shouldReceive('exists')->andReturn(false); + Container::setInstance($container); + + $this->expectException(InvalidArgumentException::class); + + $this->compiler(['alert' => 'foo.bar'])->compileTags('<x-alert />'); + } + + public function testItThrowsAnExceptionForNonExistingClass() + { + $container = new Container; + $container->instance(Application::class, $app = Mockery::mock(Application::class)); + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + $factory->shouldReceive('exists')->andReturn(false); + Container::setInstance($container); + + $this->expectException(InvalidArgumentException::class); + + $this->compiler()->compileTags('<x-alert />'); + } + + protected function mockViewFactory($existsSucceeds = true) + { + $container = new Container; + $container->instance(Factory::class, $factory = Mockery::mock(Factory::class)); + $factory->shouldReceive('exists')->andReturn($existsSucceeds); + Container::setInstance($container); + } + + protected function compiler($aliases = []) + { + return new ComponentTagCompiler( + $aliases + ); + } +} + +class TestAlertComponent extends Component +{ + public $title; + + public function __construct($title = 'foo', $userId = 1) + { + $this->title = $title; + } + + public function render() + { + return 'alert'; + } +} + +class TestProfileComponent extends Component +{ + public $userId; + + public function __construct($userId = 'foo') + { + $this->userId = $userId; + } + + public function render() + { + return 'profile'; + } +} diff --git a/tests/View/Blade/BladeComponentsTest.php b/tests/View/Blade/BladeComponentsTest.php index 24b589e84218b3908fae549976061637f398d491..83c5d2bfe6fd48b8bccda0f5ba9da2a7c53f34e2 100644 --- a/tests/View/Blade/BladeComponentsTest.php +++ b/tests/View/Blade/BladeComponentsTest.php @@ -10,14 +10,37 @@ class BladeComponentsTest extends AbstractBladeTestCase $this->assertSame('<?php $__env->startComponent(\'foo\'); ?>', $this->compiler->compileString('@component(\'foo\')')); } + public function testClassComponentsAreCompiled() + { + $this->assertSame('<?php if (isset($component)) { $__componentOriginal35bda42cbf6f9717b161c4f893644ac7a48b0d98 = $component; } ?> +<?php $component = $__env->getContainer()->make(Test::class, ["foo" => "bar"]); ?> +<?php $component->withName(\'test\'); ?> +<?php if ($component->shouldRender()): ?> +<?php $__env->startComponent($component->resolveView(), $component->data()); ?>', $this->compiler->compileString('@component(\'Test::class\', \'test\', ["foo" => "bar"])')); + } + public function testEndComponentsAreCompiled() { + $this->compiler->newComponentHash('foo'); + $this->assertSame('<?php echo $__env->renderComponent(); ?>', $this->compiler->compileString('@endcomponent')); } + public function testEndComponentClassesAreCompiled() + { + $this->compiler->newComponentHash('foo'); + + $this->assertSame('<?php echo $__env->renderComponent(); ?> +<?php endif; ?> +<?php if (isset($__componentOriginal0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33)): ?> +<?php $component = $__componentOriginal0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33; ?> +<?php unset($__componentOriginal0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33); ?> +<?php endif; ?>', $this->compiler->compileString('@endcomponentClass')); + } + public function testSlotsAreCompiled() { - $this->assertSame('<?php $__env->slot(\'foo\', ["foo" => "bar"]); ?>', $this->compiler->compileString('@slot(\'foo\', ["foo" => "bar"])')); + $this->assertSame('<?php $__env->slot(\'foo\', null, ["foo" => "bar"]); ?>', $this->compiler->compileString('@slot(\'foo\', null, ["foo" => "bar"])')); $this->assertSame('<?php $__env->slot(\'foo\'); ?>', $this->compiler->compileString('@slot(\'foo\')')); } diff --git a/tests/View/Blade/BladeCustomTest.php b/tests/View/Blade/BladeCustomTest.php index b2b947aa115bb21b938faf479d7ff8bfa5b5ec18..9b09449baf9f6287cfbf492a5023c5c710e96b81 100644 --- a/tests/View/Blade/BladeCustomTest.php +++ b/tests/View/Blade/BladeCustomTest.php @@ -157,7 +157,7 @@ class BladeCustomTest extends AbstractBladeTestCase public function testCustomComponents() { - $this->compiler->component('app.components.alert', 'alert'); + $this->compiler->aliasComponent('app.components.alert', 'alert'); $string = '@alert @endalert'; @@ -168,7 +168,7 @@ class BladeCustomTest extends AbstractBladeTestCase public function testCustomComponentsWithSlots() { - $this->compiler->component('app.components.alert', 'alert'); + $this->compiler->aliasComponent('app.components.alert', 'alert'); $string = '@alert([\'type\' => \'danger\']) @endalert'; @@ -177,20 +177,9 @@ class BladeCustomTest extends AbstractBladeTestCase $this->assertEquals($expected, $this->compiler->compileString($string)); } - public function testCustomComponentsDefaultAlias() - { - $this->compiler->component('app.components.alert'); - - $string = '@alert -@endalert'; - $expected = '<?php $__env->startComponent(\'app.components.alert\'); ?> -<?php echo $__env->renderComponent(); ?>'; - $this->assertEquals($expected, $this->compiler->compileString($string)); - } - public function testCustomComponentsWithExistingDirective() { - $this->compiler->component('app.components.foreach'); + $this->compiler->aliasComponent('app.components.foreach', 'foreach'); $string = '@foreach @endforeach'; diff --git a/tests/View/Blade/BladeEchoHandlerTest.php b/tests/View/Blade/BladeEchoHandlerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3c92049768ebd412bc099954c3430cdc9e0673f4 --- /dev/null +++ b/tests/View/Blade/BladeEchoHandlerTest.php @@ -0,0 +1,109 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +use Exception; +use Illuminate\Support\Fluent; +use Illuminate\Support\Str; + +class BladeEchoHandlerTest extends AbstractBladeTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->compiler->stringable(function (Fluent $object) { + return 'Hello World'; + }); + } + + public function testBladeHandlerCanInterceptRegularEchos() + { + $this->assertSame( + "<?php \$__bladeCompiler = app('blade.compiler'); ?><?php echo e(\$__bladeCompiler->applyEchoHandler(\$exampleObject)); ?>", + $this->compiler->compileString('{{$exampleObject}}') + ); + } + + public function testBladeHandlerCanInterceptRawEchos() + { + $this->assertSame( + "<?php \$__bladeCompiler = app('blade.compiler'); ?><?php echo \$__bladeCompiler->applyEchoHandler(\$exampleObject); ?>", + $this->compiler->compileString('{!!$exampleObject!!}') + ); + } + + public function testBladeHandlerCanInterceptEscapedEchos() + { + $this->assertSame( + "<?php \$__bladeCompiler = app('blade.compiler'); ?><?php echo e(\$__bladeCompiler->applyEchoHandler(\$exampleObject)); ?>", + $this->compiler->compileString('{{{$exampleObject}}}') + ); + } + + public function testWhitespaceIsPreservedCorrectly() + { + $this->assertSame( + "<?php \$__bladeCompiler = app('blade.compiler'); ?><?php echo e(\$__bladeCompiler->applyEchoHandler(\$exampleObject)); ?>\n\n", + $this->compiler->compileString("{{\$exampleObject}}\n") + ); + } + + /** + * @dataProvider handlerLogicDataProvider + */ + public function testHandlerLogicWorksCorrectly($blade) + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The fluent object has been successfully handled!'); + + $this->compiler->stringable(Fluent::class, function ($object) { + throw new Exception('The fluent object has been successfully handled!'); + }); + + app()->singleton('blade.compiler', function () { + return $this->compiler; + }); + + $exampleObject = new Fluent(); + + eval(Str::of($this->compiler->compileString($blade))->remove(['<?php', '?>'])); + } + + public function handlerLogicDataProvider() + { + return [ + ['{{$exampleObject}}'], + ['{{$exampleObject;}}'], + ['{{{$exampleObject;}}}'], + ['{!!$exampleObject;!!}'], + ]; + } + + /** + * @dataProvider nonStringableDataProvider + */ + public function testHandlerWorksWithNonStringables($blade, $expectedOutput) + { + app()->singleton('blade.compiler', function () { + return $this->compiler; + }); + + ob_start(); + eval(Str::of($this->compiler->compileString($blade))->remove(['<?php', '?>'])); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame($expectedOutput, $output); + } + + public function nonStringableDataProvider() + { + return [ + ['{{"foo" . "bar"}}', 'foobar'], + ['{{ 1 + 2 }}{{ "test"; }}', '3test'], + ['@php($test = "hi"){{ $test }}', 'hi'], + ['{!! " " !!}', ' '], + ]; + } +} diff --git a/tests/View/Blade/BladeEnvironmentStatementsTest.php b/tests/View/Blade/BladeEnvironmentStatementsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..866b9e4c40db792f0d8ec7b777bd9c01d4b0c6b5 --- /dev/null +++ b/tests/View/Blade/BladeEnvironmentStatementsTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +class BladeEnvironmentStatementsTest extends AbstractBladeTestCase +{ + public function testEnvStatementsAreCompiled() + { + $string = "@env('staging') +breeze +@else +boom +@endenv"; + $expected = "<?php if(app()->environment('staging')): ?> +breeze +<?php else: ?> +boom +<?php endif; ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEnvStatementsWithMultipleStringParamsAreCompiled() + { + $string = "@env('staging', 'production') +breeze +@else +boom +@endenv"; + $expected = "<?php if(app()->environment('staging', 'production')): ?> +breeze +<?php else: ?> +boom +<?php endif; ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEnvStatementsWithArrayParamAreCompiled() + { + $string = "@env(['staging', 'production']) +breeze +@else +boom +@endenv"; + $expected = "<?php if(app()->environment(['staging', 'production'])): ?> +breeze +<?php else: ?> +boom +<?php endif; ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testProductionStatementsAreCompiled() + { + $string = '@production +breeze +@else +boom +@endproduction'; + $expected = "<?php if(app()->environment('production')): ?> +breeze +<?php else: ?> +boom +<?php endif; ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeExtendsTest.php b/tests/View/Blade/BladeExtendsTest.php index 1690b03759e67d8043edbe67f0977b68823e3cb4..fff14e9115c302f7e5df6c6e5fe230493ff768eb 100644 --- a/tests/View/Blade/BladeExtendsTest.php +++ b/tests/View/Blade/BladeExtendsTest.php @@ -8,11 +8,11 @@ class BladeExtendsTest extends AbstractBladeTestCase { $string = '@extends(\'foo\') test'; - $expected = 'test'.PHP_EOL.'<?php echo $__env->make(\'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; + $expected = "test\n".'<?php echo $__env->make(\'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); - $string = '@extends(name(foo))'.PHP_EOL.'test'; - $expected = 'test'.PHP_EOL.'<?php echo $__env->make(name(foo), \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; + $string = '@extends(name(foo))'."\n".'test'; + $expected = "test\n".'<?php echo $__env->make(name(foo), \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); } @@ -20,12 +20,12 @@ test'; { $string = '@extends(\'foo\') test'; - $expected = 'test'.PHP_EOL.'<?php echo $__env->make(\'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; + $expected = "test\n".'<?php echo $__env->make(\'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); // use the same compiler instance to compile another template with @extends directive - $string = '@extends(name(foo))'.PHP_EOL.'test'; - $expected = 'test'.PHP_EOL.'<?php echo $__env->make(name(foo), \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; + $string = "@extends(name(foo))\ntest"; + $expected = "test\n".'<?php echo $__env->make(name(foo), \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); } } diff --git a/tests/View/Blade/BladeForeachStatementsTest.php b/tests/View/Blade/BladeForeachStatementsTest.php index a7e83df7febc98c476fc27ad9b84d7167bfa4a46..254ff36f1d8e8c8067021f91d32de26b0886b843 100644 --- a/tests/View/Blade/BladeForeachStatementsTest.php +++ b/tests/View/Blade/BladeForeachStatementsTest.php @@ -81,5 +81,14 @@ tag info $string = '@foreach ($tasks as $task)'; $expected = '<?php $__currentLoopData = $tasks; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $task): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@foreach(resolve('App\\\\DataProviders\\\\'.\$provider)->data() as \$key => \$value) + <input {{ \$foo ? 'bar': 'baz' }}> +@endforeach"; + $expected = "<?php \$__currentLoopData = resolve('App\\\\DataProviders\\\\'.\$provider)->data(); \$__env->addLoop(\$__currentLoopData); foreach(\$__currentLoopData as \$key => \$value): \$__env->incrementLoopIndices(); \$loop = \$__env->getLastLoop(); ?> + <input <?php echo e(\$foo ? 'bar': 'baz'); ?>> +<?php endforeach; \$__env->popLoop(); \$loop = \$__env->getLastLoop(); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); } } diff --git a/tests/View/Blade/BladeIncludesTest.php b/tests/View/Blade/BladeIncludesTest.php index 1273ded2c138c8eea04e76ed94e8428334511edf..0cf5e3a1d93150eabcffc02e9d9095b797faa96a 100644 --- a/tests/View/Blade/BladeIncludesTest.php +++ b/tests/View/Blade/BladeIncludesTest.php @@ -28,6 +28,12 @@ class BladeIncludesTest extends AbstractBladeTestCase $this->assertSame('<?php echo $__env->renderWhen(true, \'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\'])); ?>', $this->compiler->compileString('@includeWhen(true, \'foo\')')); } + public function testIncludeUnlessesAreCompiled() + { + $this->assertSame('<?php echo $__env->renderUnless(true, \'foo\', ["foo" => "bar"], \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\'])); ?>', $this->compiler->compileString('@includeUnless(true, \'foo\', ["foo" => "bar"])')); + $this->assertSame('<?php echo $__env->renderUnless($undefined ?? true, \'foo\', \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\'])); ?>', $this->compiler->compileString('@includeUnless($undefined ?? true, \'foo\')')); + } + public function testIncludeFirstsAreCompiled() { $this->assertSame('<?php echo $__env->first(["one", "two"], \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?>', $this->compiler->compileString('@includeFirst(["one", "two"])')); diff --git a/tests/View/Blade/BladeInjectTest.php b/tests/View/Blade/BladeInjectTest.php new file mode 100644 index 0000000000000000000000000000000000000000..07ffd19f0e3f584cf38814514f5fc5fabd8cc314 --- /dev/null +++ b/tests/View/Blade/BladeInjectTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +class BladeInjectTest extends AbstractBladeTestCase +{ + public function testDependenciesInjectedAsStringsAreCompiled() + { + $string = "Foo @inject('baz', 'SomeNamespace\SomeClass') bar"; + $expected = "Foo <?php \$baz = app('SomeNamespace\SomeClass'); ?> bar"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesInjectedAsStringsAreCompiledWhenInjectedWithDoubleQuotes() + { + $string = 'Foo @inject("baz", "SomeNamespace\SomeClass") bar'; + $expected = 'Foo <?php $baz = app("SomeNamespace\SomeClass"); ?> bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesAreCompiled() + { + $string = "Foo @inject('baz', SomeNamespace\SomeClass::class) bar"; + $expected = "Foo <?php \$baz = app(SomeNamespace\SomeClass::class); ?> bar"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesAreCompiledWithDoubleQuotes() + { + $string = 'Foo @inject("baz", SomeNamespace\SomeClass::class) bar'; + $expected = "Foo <?php \$baz = app(SomeNamespace\SomeClass::class); ?> bar"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeJsTest.php b/tests/View/Blade/BladeJsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..be63c8f19e3b5cfcc306dd94321afd8fcbae788d --- /dev/null +++ b/tests/View/Blade/BladeJsTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +class BladeJsTest extends AbstractBladeTestCase +{ + public function testStatementIsCompiledWithoutAnyOptions() + { + $string = '<div x-data="@js($data)"></div>'; + $expected = '<div x-data="<?php echo \Illuminate\Support\Js::from($data)->toHtml() ?>"></div>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testJsonFlagsCanBeSet() + { + $string = '<div x-data="@js($data, JSON_FORCE_OBJECT)"></div>'; + $expected = '<div x-data="<?php echo \Illuminate\Support\Js::from($data, JSON_FORCE_OBJECT)->toHtml() ?>"></div>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEncodingDepthCanBeSet() + { + $string = '<div x-data="@js($data, JSON_FORCE_OBJECT, 256)"></div>'; + $expected = '<div x-data="<?php echo \Illuminate\Support\Js::from($data, JSON_FORCE_OBJECT, 256)->toHtml() ?>"></div>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladePhpStatementsTest.php b/tests/View/Blade/BladePhpStatementsTest.php index 410412f9336d30bdd535495039998c4e0af1b175..8f7a9f707965d7faa776618f2af5c197aff76533 100644 --- a/tests/View/Blade/BladePhpStatementsTest.php +++ b/tests/View/Blade/BladePhpStatementsTest.php @@ -43,4 +43,45 @@ class BladePhpStatementsTest extends AbstractBladeTestCase $this->assertEquals($expected, $this->compiler->compileString($string)); } + + public function testStringWithParenthesisCannotBeCompiled() + { + $string = "@php(\$data = ['test' => ')'])"; + + $expected = "<?php (\$data = ['test' => ')']); ?>"; + + $actual = "<?php (\$data = ['test' => '); ?>'])"; + + $this->assertEquals($actual, $this->compiler->compileString($string)); + } + + public function testStringWithEmptyStringDataValue() + { + $string = "@php(\$data = ['test' => ''])"; + + $expected = "<?php (\$data = ['test' => '']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['test' => \"\"])"; + + $expected = "<?php (\$data = ['test' => \"\"]); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testStringWithEscapingDataValue() + { + $string = "@php(\$data = ['test' => 'won\\'t break'])"; + + $expected = "<?php (\$data = ['test' => 'won\\'t break']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['test' => \"\\\"escaped\\\"\"])"; + + $expected = "<?php (\$data = ['test' => \"\\\"escaped\\\"\"]); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } } diff --git a/tests/View/Blade/BladeSectionMissingTest.php b/tests/View/Blade/BladeSectionMissingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fb600a1a3dbd4c124811d7b429aec8c92bebb22e --- /dev/null +++ b/tests/View/Blade/BladeSectionMissingTest.php @@ -0,0 +1,17 @@ +<?php + +namespace Illuminate\Tests\View\Blade; + +class BladeSectionMissingTest extends AbstractBladeTestCase +{ + public function testSectionMissingStatementsAreCompiled() + { + $string = '@sectionMissing("section") +breeze +@endif'; + $expected = '<?php if (empty(trim($__env->yieldContent("section")))): ?> +breeze +<?php endif; ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeVerbatimTest.php b/tests/View/Blade/BladeVerbatimTest.php index fb1f1bb2d91097135660b26ba5c3e4597587c338..dfe3eded7fb73d763ff38e641f7cc06406d0fdb6 100644 --- a/tests/View/Blade/BladeVerbatimTest.php +++ b/tests/View/Blade/BladeVerbatimTest.php @@ -58,7 +58,7 @@ class BladeVerbatimTest extends AbstractBladeTestCase @include("users") @verbatim {{ $fourth }} @include("test") -@endverbatim +@endverbatim @php echo $fifth; @endphp'; $expected = '<?php echo e($first); ?> @@ -73,7 +73,7 @@ class BladeVerbatimTest extends AbstractBladeTestCase <?php echo $__env->make("users", \Illuminate\Support\Arr::except(get_defined_vars(), [\'__data\', \'__path\']))->render(); ?> {{ $fourth }} @include("test") - + <?php echo $fifth; ?>'; $this->assertSame($expected, $this->compiler->compileString($string)); diff --git a/tests/View/ComponentTest.php b/tests/View/ComponentTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6185daa23ddcf0d752cedba5f548aa85d7b4378c --- /dev/null +++ b/tests/View/ComponentTest.php @@ -0,0 +1,148 @@ +<?php + +namespace Illuminate\Tests\View; + +use Illuminate\Config\Repository as Config; +use Illuminate\Container\Container; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\View\Factory as FactoryContract; +use Illuminate\Support\Facades\Facade; +use Illuminate\Support\HtmlString; +use Illuminate\View\Component; +use Illuminate\View\Factory; +use Illuminate\View\View; +use Mockery as m; +use PHPUnit\Framework\TestCase; + +class ComponentTest extends TestCase +{ + protected $viewFactory; + protected $config; + + protected function setUp(): void + { + $this->config = m::mock(Config::class); + + $container = new Container; + + $this->viewFactory = m::mock(Factory::class); + + $container->instance('view', $this->viewFactory); + $container->alias('view', FactoryContract::class); + $container->instance('config', $this->config); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + + parent::setUp(); + } + + protected function tearDown(): void + { + m::close(); + + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Container::setInstance(null); + } + + public function testInlineViewsGetCreated() + { + $this->config->shouldReceive('get')->once()->with('view.compiled')->andReturn('/tmp'); + $this->viewFactory->shouldReceive('exists')->once()->andReturn(false); + $this->viewFactory->shouldReceive('addNamespace')->once()->with('__components', '/tmp'); + + $component = new TestInlineViewComponent; + $this->assertSame('__components::c6327913fef3fca4518bcd7df1d0ff630758e241', $component->resolveView()); + } + + public function testRegularViewsGetReturned() + { + $view = m::mock(View::class); + $this->viewFactory->shouldReceive('make')->once()->with('alert', [], [])->andReturn($view); + + $component = new TestRegularViewComponent; + + $this->assertSame($view, $component->resolveView()); + } + + public function testRegularViewNamesGetReturned() + { + $this->viewFactory->shouldReceive('exists')->once()->andReturn(true); + $this->viewFactory->shouldReceive('addNamespace')->never(); + + $component = new TestRegularViewNameViewComponent; + + $this->assertSame('alert', $component->resolveView()); + } + + public function testHtmlablesGetReturned() + { + $component = new TestHtmlableReturningViewComponent; + + $view = $component->resolveView(); + + $this->assertInstanceOf(Htmlable::class, $view); + $this->assertSame('<p>Hello foo</p>', $view->toHtml()); + } +} + +class TestInlineViewComponent extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render() + { + return 'Hello {{ $title }}'; + } +} + +class TestRegularViewComponent extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render() + { + return view('alert'); + } +} + +class TestRegularViewNameViewComponent extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render() + { + return 'alert'; + } +} + +class TestHtmlableReturningViewComponent extends Component +{ + protected $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render() + { + return new HtmlString("<p>Hello {$this->title}</p>"); + } +} diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php index fd61b8c46a5bb5907dac01a27417d6c394d9d3ab..2be9e8760fbf16c55be84498fb6223c7cc2f24f9 100644 --- a/tests/View/ViewBladeCompilerTest.php +++ b/tests/View/ViewBladeCompilerTest.php @@ -18,7 +18,7 @@ class ViewBladeCompilerTest extends TestCase public function testIsExpiredReturnsTrueIfCompiledFileDoesntExist() { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); - $files->shouldReceive('exists')->once()->with(__DIR__.'/'.sha1('foo').'.php')->andReturn(false); + $files->shouldReceive('exists')->once()->with(__DIR__.'/'.sha1('v2foo').'.php')->andReturn(false); $this->assertTrue($compiler->isExpired('foo')); } @@ -33,23 +33,34 @@ class ViewBladeCompilerTest extends TestCase public function testIsExpiredReturnsTrueWhenModificationTimesWarrant() { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); - $files->shouldReceive('exists')->once()->with(__DIR__.'/'.sha1('foo').'.php')->andReturn(true); + $files->shouldReceive('exists')->once()->with(__DIR__.'/'.sha1('v2foo').'.php')->andReturn(true); $files->shouldReceive('lastModified')->once()->with('foo')->andReturn(100); - $files->shouldReceive('lastModified')->once()->with(__DIR__.'/'.sha1('foo').'.php')->andReturn(0); + $files->shouldReceive('lastModified')->once()->with(__DIR__.'/'.sha1('v2foo').'.php')->andReturn(0); $this->assertTrue($compiler->isExpired('foo')); } public function testCompilePathIsProperlyCreated() { $compiler = new BladeCompiler($this->getFiles(), __DIR__); - $this->assertEquals(__DIR__.'/'.sha1('foo').'.php', $compiler->getCompiledPath('foo')); + $this->assertEquals(__DIR__.'/'.sha1('v2foo').'.php', $compiler->getCompiledPath('foo')); } public function testCompileCompilesFileAndReturnsContents() { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); + $compiler->compile('foo'); + } + + public function testCompileCompilesFileAndReturnsContentsCreatingDirectory() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(false); + $files->shouldReceive('makeDirectory')->once()->with(__DIR__, 0777, true, true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); $compiler->compile('foo'); } @@ -57,7 +68,8 @@ class ViewBladeCompilerTest extends TestCase { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); $compiler->compile('foo'); $this->assertSame('foo', $compiler->getPath()); } @@ -73,7 +85,8 @@ class ViewBladeCompilerTest extends TestCase { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2foo').'.php', 'Hello World<?php /**PATH foo ENDPATH**/ ?>'); // set path before compilation $compiler->setPath('foo'); // trigger compilation with $path @@ -94,16 +107,17 @@ class ViewBladeCompilerTest extends TestCase } /** + * @dataProvider appendViewPathDataProvider + * * @param string $content * @param string $compiled - * - * @dataProvider appendViewPathDataProvider */ public function testIncludePathToTemplate($content, $compiled) { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with('foo')->andReturn($content); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('foo').'.php', $compiled); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2foo').'.php', $compiled); $compiler->compile('foo'); } @@ -157,7 +171,8 @@ class ViewBladeCompilerTest extends TestCase { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with('')->andReturn('Hello World'); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('').'.php', 'Hello World'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2').'.php', 'Hello World'); $compiler->setPath(''); $compiler->compile(); } @@ -166,7 +181,8 @@ class ViewBladeCompilerTest extends TestCase { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $files->shouldReceive('get')->once()->with(null)->andReturn('Hello World'); - $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1(null).'.php', 'Hello World'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__.'/'.sha1('v2').'.php', 'Hello World'); $compiler->setPath(null); $compiler->compile(); } @@ -175,8 +191,31 @@ class ViewBladeCompilerTest extends TestCase { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); $strictTypeDecl = "<?php\ndeclare(strict_types = 1);"; - $this->assertTrue(substr($compiler->compileString("<?php\ndeclare(strict_types = 1);\nHello World"), - 0, strlen($strictTypeDecl)) === $strictTypeDecl); + $this->assertSame(substr($compiler->compileString("<?php\ndeclare(strict_types = 1);\nHello World"), + 0, strlen($strictTypeDecl)), $strictTypeDecl); + } + + public function testComponentAliasesCanBeConventionallyDetermined() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\Foo\Bar'); + $this->assertEquals(['bar' => 'App\Foo\Bar'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\Foo\Bar', null, 'prefix'); + $this->assertEquals(['prefix-bar' => 'App\Foo\Bar'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\View\Components\Forms\Input'); + $this->assertEquals(['forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\View\Components\Forms\Input', null, 'prefix'); + $this->assertEquals(['prefix-forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases()); } protected function getFiles() diff --git a/tests/View/ViewCompilerEngineTest.php b/tests/View/ViewCompilerEngineTest.php index 2237110980ff8f50f05e722029bba2325b0d2678..f826e9656ac324b9ee8e45700332f56fb43a3a44 100755 --- a/tests/View/ViewCompilerEngineTest.php +++ b/tests/View/ViewCompilerEngineTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\View; +use Illuminate\Filesystem\Filesystem; use Illuminate\View\Compilers\CompilerInterface; use Illuminate\View\Engines\CompilerEngine; use Mockery as m; @@ -40,6 +41,6 @@ class ViewCompilerEngineTest extends TestCase protected function getEngine() { - return new CompilerEngine(m::mock(CompilerInterface::class)); + return new CompilerEngine(m::mock(CompilerInterface::class), new Filesystem); } } diff --git a/tests/View/ViewComponentAttributeBagTest.php b/tests/View/ViewComponentAttributeBagTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4fab0a42d37e0e1a22bf64961b7f7417dd5b7474 --- /dev/null +++ b/tests/View/ViewComponentAttributeBagTest.php @@ -0,0 +1,71 @@ +<?php + +namespace Illuminate\Tests\View; + +use Illuminate\View\ComponentAttributeBag; +use PHPUnit\Framework\TestCase; + +class ViewComponentAttributeBagTest extends TestCase +{ + public function testAttributeRetrieval() + { + $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test']); + + $this->assertSame('class="font-bold"', (string) $bag->whereStartsWith('class')); + $this->assertSame('font-bold', (string) $bag->whereStartsWith('class')->first()); + $this->assertSame('name="test"', (string) $bag->whereDoesntStartWith('class')); + $this->assertSame('test', (string) $bag->whereDoesntStartWith('class')->first()); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->merge(['class' => 'mt-4'])); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->merge(['class' => 'mt-4', 'name' => 'foo'])); + $this->assertSame('class="mt-4 font-bold" id="bar" name="test"', (string) $bag->merge(['class' => 'mt-4', 'id' => 'bar'])); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag(['class' => 'mt-4'])); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->only('class')->merge(['class' => 'mt-4'])); + $this->assertSame('name="test" class="font-bold"', (string) $bag->merge(['name' => 'default'])); + $this->assertSame('class="font-bold" name="test"', (string) $bag->merge([])); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->merge(['class' => 'mt-4'])->only('class')); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->only('class')(['class' => 'mt-4'])); + $this->assertSame('font-bold', $bag->get('class')); + $this->assertSame('bar', $bag->get('foo', 'bar')); + $this->assertSame('font-bold', $bag['class']); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->class('mt-4')); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->class(['mt-4'])); + $this->assertSame('class="mt-4 ml-2 font-bold" name="test"', (string) $bag->class(['mt-4', 'ml-2' => true, 'mr-2' => false])); + + $bag = new ComponentAttributeBag([]); + + $this->assertSame('class="mt-4"', (string) $bag->merge(['class' => 'mt-4'])); + + $bag = new ComponentAttributeBag([ + 'test-string' => 'ok', + 'test-null' => null, + 'test-false' => false, + 'test-true' => true, + 'test-0' => 0, + 'test-0-string' => '0', + 'test-empty-string' => '', + ]); + + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag); + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag->merge()); + + $bag = (new ComponentAttributeBag) + ->merge([ + 'test-escaped' => '<tag attr="attr">', + ]); + + $this->assertSame('test-escaped="<tag attr="attr">"', (string) $bag); + + $bag = (new ComponentAttributeBag) + ->merge([ + 'test-string' => 'ok', + 'test-null' => null, + 'test-false' => false, + 'test-true' => true, + 'test-0' => 0, + 'test-0-string' => '0', + 'test-empty-string' => '', + ]); + + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag); + } +} diff --git a/tests/View/ViewComponentTest.php b/tests/View/ViewComponentTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c7ac6cda96ec9c0113fe026eae57cc1e2a624dac --- /dev/null +++ b/tests/View/ViewComponentTest.php @@ -0,0 +1,199 @@ +<?php + +namespace Illuminate\Tests\View; + +use Illuminate\View\Component; +use Illuminate\View\ComponentAttributeBag; +use PHPUnit\Framework\TestCase; + +class ViewComponentTest extends TestCase +{ + public function testDataExposure() + { + $component = new TestViewComponent; + + $variables = $component->data(); + + $this->assertEquals(10, $variables['votes']); + $this->assertSame('world', $variables['hello']()); + $this->assertSame('taylor', $variables['hello']('taylor')); + } + + public function testAttributeParentInheritance() + { + $component = new TestViewComponent; + + $component->withAttributes(['class' => 'foo', 'attributes' => new ComponentAttributeBag(['class' => 'bar', 'type' => 'button'])]); + + $this->assertSame('class="foo bar" type="button"', (string) $component->attributes); + } + + public function testPublicMethodsWithNoArgsAreConvertedToStringableCallablesInvokedAndNotCached() + { + $component = new TestSampleViewComponent; + + $this->assertEquals(0, $component->counter); + $this->assertEquals(0, TestSampleViewComponent::$publicStaticCounter); + $variables = $component->data(); + $this->assertEquals(0, $component->counter); + $this->assertEquals(0, TestSampleViewComponent::$publicStaticCounter); + + $this->assertSame('noArgs val', $variables['noArgs']()); + $this->assertSame('noArgs val', (string) $variables['noArgs']); + $this->assertEquals(0, $variables['counter']); + + // make sure non-public members are not invoked nor counted. + $this->assertEquals(2, $component->counter); + $this->assertArrayHasKey('publicHello', $variables); + $this->assertArrayNotHasKey('protectedHello', $variables); + $this->assertArrayNotHasKey('privateHello', $variables); + + $this->assertArrayNotHasKey('publicStaticCounter', $variables); + $this->assertArrayNotHasKey('protectedCounter', $variables); + $this->assertArrayNotHasKey('privateCounter', $variables); + + // test each time we invoke data(), the non-argument methods aren't invoked + $this->assertEquals(2, $component->counter); + $component->data(); + $this->assertEquals(2, $component->counter); + $component->data(); + $this->assertEquals(2, $component->counter); + } + + public function testItIgnoresExceptedMethodsAndProperties() + { + $component = new TestExceptedViewComponent; + $variables = $component->data(); + + // Ignored methods (with no args) are not invoked behind the scenes. + $this->assertSame('Otwell', $component->taylor); + + $this->assertArrayNotHasKey('hello', $variables); + $this->assertArrayNotHasKey('hello2', $variables); + $this->assertArrayNotHasKey('taylor', $variables); + } + + public function testMethodsOverridePropertyValues() + { + $component = new TestHelloPropertyHelloMethodComponent; + $variables = $component->data(); + $this->assertArrayHasKey('hello', $variables); + $this->assertSame('world', $variables['hello']()); + + // protected methods do not override public properties. + $this->assertArrayHasKey('world', $variables); + $this->assertSame('world property', $variables['world']); + } +} + +class TestViewComponent extends Component +{ + public $votes = 10; + + public function render() + { + return 'test'; + } + + public function hello($string = 'world') + { + return $string; + } +} + +class TestSampleViewComponent extends Component +{ + public $counter = 0; + + public static $publicStaticCounter = 0; + + protected $protectedCounter = 0; + + private $privateCounter = 0; + + public function render() + { + return 'test'; + } + + public function publicHello($string = 'world') + { + $this->counter = 100; + + return $string; + } + + public function noArgs() + { + $this->counter++; + + return 'noArgs val'; + } + + protected function protectedHello() + { + $this->counter++; + } + + private function privateHello() + { + $this->counter++; + } +} + +class TestExceptedViewComponent extends Component +{ + protected $except = ['hello', 'hello2', 'taylor']; + + public $taylor = 'Otwell'; + + public function hello($string = 'world') + { + return $string; + } + + public function hello2() + { + return $this->taylor = ''; + } + + public function render() + { + return 'test'; + } +} + +class TestHelloPropertyHelloMethodComponent extends Component +{ + public function render() + { + return 'test'; + } + + public $hello = 'hello property'; + + public $world = 'world property'; + + public function hello($string = 'world') + { + return $string; + } + + protected function world($string = 'world') + { + return $string; + } +} + +class TestDefaultAttributesComponent extends Component +{ + public function __construct() + { + $this->withAttributes(['class' => 'text-red-500']); + } + + public function render() + { + return $this->attributes->get('id'); + } +} diff --git a/tests/View/ViewFactoryTest.php b/tests/View/ViewFactoryTest.php index adca1d9427719d38da81fbc420080a7bf437165f..a3b2a316c2cdd23e70b0d987dabddc44047a7955 100755 --- a/tests/View/ViewFactoryTest.php +++ b/tests/View/ViewFactoryTest.php @@ -7,7 +7,10 @@ use ErrorException; use Illuminate\Container\Container; use Illuminate\Contracts\Events\Dispatcher as DispatcherContract; use Illuminate\Contracts\View\Engine; +use Illuminate\Contracts\View\View as ViewContract; use Illuminate\Events\Dispatcher; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\HtmlString; use Illuminate\View\Compilers\CompilerInterface; use Illuminate\View\Engines\CompilerEngine; use Illuminate\View\Engines\EngineResolver; @@ -59,6 +62,16 @@ class ViewFactoryTest extends TestCase $this->assertTrue($factory->exists('bar')); } + public function testRenderingOnceChecks() + { + $factory = $this->getFactory(); + $this->assertFalse($factory->hasRenderedOnce('foo')); + $factory->markAsRenderedOnce('foo'); + $this->assertTrue($factory->hasRenderedOnce('foo')); + $factory->flushState(); + $this->assertFalse($factory->hasRenderedOnce('foo')); + } + public function testFirstCreatesNewViewInstanceWithProperPath() { unset($_SERVER['__test.view']); @@ -75,6 +88,7 @@ class ViewFactoryTest extends TestCase $factory->addExtension('php', 'php'); $view = $factory->first(['bar', 'view'], ['foo' => 'bar'], ['baz' => 'boom']); + $this->assertInstanceOf(ViewContract::class, $view); $this->assertSame($engine, $view->getEngine()); $this->assertSame($_SERVER['__test.view'], $view); @@ -337,11 +351,48 @@ class ViewFactoryTest extends TestCase { $factory = $this->getFactory(); $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__.'/fixtures/component.php'); - $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem)); $factory->getDispatcher()->shouldReceive('dispatch'); $factory->startComponent('component', ['name' => 'Taylor']); $factory->slot('title'); - $factory->slot('website', 'laravel.com'); + $factory->slot('website', 'laravel.com', []); + echo 'title<hr>'; + $factory->endSlot(); + echo 'component'; + $contents = $factory->renderComponent(); + $this->assertSame('title<hr> component Taylor laravel.com', $contents); + } + + public function testComponentHandlingUsingViewObject() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__.'/fixtures/component.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem)); + $factory->getDispatcher()->shouldReceive('dispatch'); + $factory->startComponent($factory->make('component'), ['name' => 'Taylor']); + $factory->slot('title'); + $factory->slot('website', 'laravel.com', []); + echo 'title<hr>'; + $factory->endSlot(); + echo 'component'; + $contents = $factory->renderComponent(); + $this->assertSame('title<hr> component Taylor laravel.com', $contents); + } + + public function testComponentHandlingUsingClosure() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__.'/fixtures/component.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem)); + $factory->getDispatcher()->shouldReceive('dispatch'); + $factory->startComponent(function ($data) use ($factory) { + $this->assertArrayHasKey('name', $data); + $this->assertSame($data['name'], 'Taylor'); + + return $factory->make('component'); + }, ['name' => 'Taylor']); + $factory->slot('title'); + $factory->slot('website', 'laravel.com', []); echo 'title<hr>'; $factory->endSlot(); echo 'component'; @@ -349,6 +400,14 @@ class ViewFactoryTest extends TestCase $this->assertSame('title<hr> component Taylor laravel.com', $contents); } + public function testComponentHandlingUsingHtmlable() + { + $factory = $this->getFactory(); + $factory->startComponent(new HtmlString('laravel.com')); + $contents = $factory->renderComponent(); + $this->assertSame('laravel.com', $contents); + } + public function testTranslation() { $container = new Container; @@ -384,6 +443,27 @@ class ViewFactoryTest extends TestCase $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); } + public function testSingleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi', $factory->yieldPushContent('foo')); + } + + public function testMultipleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo ', Hello!'; + $factory->stopPrepend(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); + } + public function testSessionAppending() { $factory = $this->getFactory(); @@ -442,6 +522,17 @@ class ViewFactoryTest extends TestCase $this->assertFalse($factory->hasSection('bar')); } + public function testSectionMissing() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hello world'; + $factory->stopSection(); + + $this->assertTrue($factory->sectionMissing('bar')); + $this->assertFalse($factory->sectionMissing('foo')); + } + public function testGetSection() { $factory = $this->getFactory(); @@ -488,7 +579,7 @@ class ViewFactoryTest extends TestCase $this->expectException(ErrorException::class); $this->expectExceptionMessage('section exception message'); - $engine = new CompilerEngine(m::mock(CompilerInterface::class)); + $engine = new CompilerEngine(m::mock(CompilerInterface::class), new Filesystem); $engine->getCompiler()->shouldReceive('getCompiledPath')->andReturnUsing(function ($path) { return $path; }); @@ -572,7 +663,8 @@ class ViewFactoryTest extends TestCase { $factory = $this->getFactory(); - $data = (new class { + $data = (new class + { public function generate() { for ($count = 0; $count < 3; $count++) { diff --git a/tests/View/ViewPhpEngineTest.php b/tests/View/ViewPhpEngineTest.php index 34f66e2a545afa82fd0f90a4ee118c9f8d9b7ea1..0f4b1de293bed0bf221d98e44cb40e911cf5a0d9 100755 --- a/tests/View/ViewPhpEngineTest.php +++ b/tests/View/ViewPhpEngineTest.php @@ -2,20 +2,15 @@ namespace Illuminate\Tests\View; +use Illuminate\Filesystem\Filesystem; use Illuminate\View\Engines\PhpEngine; -use Mockery as m; use PHPUnit\Framework\TestCase; class ViewPhpEngineTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testViewsMayBeProperlyRendered() { - $engine = new PhpEngine; + $engine = new PhpEngine(new Filesystem); $this->assertSame('Hello World ', $engine->get(__DIR__.'/fixtures/basic.php')); } diff --git a/tests/View/ViewTest.php b/tests/View/ViewTest.php index 67221f7654389e0adac82320ef1552bcfc79953a..308e2e81e0f7e6d5a9eb071ee9f16add5966f8d0 100755 --- a/tests/View/ViewTest.php +++ b/tests/View/ViewTest.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Renderable; use Illuminate\Contracts\View\Engine; use Illuminate\Support\MessageBag; +use Illuminate\Support\ViewErrorBag; use Illuminate\View\Factory; use Illuminate\View\View; use Mockery as m; @@ -126,12 +127,12 @@ class ViewTest extends TestCase public function testViewGettersSetters() { $view = $this->getView(['foo' => 'bar']); - $this->assertEquals($view->name(), 'view'); - $this->assertEquals($view->getPath(), 'path'); + $this->assertSame('view', $view->name()); + $this->assertSame('path', $view->getPath()); $data = $view->getData(); - $this->assertEquals($data['foo'], 'bar'); + $this->assertSame('bar', $data['foo']); $view->setPath('newPath'); - $this->assertEquals($view->getPath(), 'newPath'); + $this->assertSame('newPath', $view->getPath()); } public function testViewArrayAccess() @@ -139,9 +140,9 @@ class ViewTest extends TestCase $view = $this->getView(['foo' => 'bar']); $this->assertInstanceOf(ArrayAccess::class, $view); $this->assertTrue($view->offsetExists('foo')); - $this->assertEquals($view->offsetGet('foo'), 'bar'); + $this->assertSame('bar', $view->offsetGet('foo')); $view->offsetSet('foo', 'baz'); - $this->assertEquals($view->offsetGet('foo'), 'baz'); + $this->assertSame('baz', $view->offsetGet('foo')); $view->offsetUnset('foo'); $this->assertFalse($view->offsetExists('foo')); } @@ -151,9 +152,9 @@ class ViewTest extends TestCase $view = $this->getView(new DataObjectStub); $this->assertInstanceOf(ArrayAccess::class, $view); $this->assertTrue($view->offsetExists('foo')); - $this->assertEquals($view->offsetGet('foo'), 'bar'); + $this->assertSame('bar', $view->offsetGet('foo')); $view->offsetSet('foo', 'baz'); - $this->assertEquals($view->offsetGet('foo'), 'baz'); + $this->assertSame('baz', $view->offsetGet('foo')); $view->offsetUnset('foo'); $this->assertFalse($view->offsetExists('foo')); } @@ -162,9 +163,9 @@ class ViewTest extends TestCase { $view = $this->getView(['foo' => 'bar']); $this->assertTrue(isset($view->foo)); - $this->assertEquals($view->foo, 'bar'); + $this->assertSame('bar', $view->foo); $view->foo = 'baz'; - $this->assertEquals($view->foo, 'baz'); + $this->assertSame('baz', $view->foo); $this->assertEquals($view['foo'], $view->foo); unset($view->foo); $this->assertFalse(isset($view->foo)); @@ -207,8 +208,8 @@ class ViewTest extends TestCase $view->getFactory()->shouldReceive('getSections')->once()->andReturn(['foo', 'bar']); $sections = $view->renderSections(); - $this->assertEquals($sections[0], 'foo'); - $this->assertEquals($sections[1], 'bar'); + $this->assertSame('foo', $sections[0]); + $this->assertSame('bar', $sections[1]); } public function testWithErrors() @@ -216,15 +217,20 @@ class ViewTest extends TestCase $view = $this->getView(); $errors = ['foo' => 'bar', 'qu' => 'ux']; $this->assertSame($view, $view->withErrors($errors)); - $this->assertInstanceOf(MessageBag::class, $view->errors); + $this->assertInstanceOf(ViewErrorBag::class, $view->errors); $foo = $view->errors->get('foo'); - $this->assertEquals($foo[0], 'bar'); + $this->assertSame('bar', $foo[0]); $qu = $view->errors->get('qu'); - $this->assertEquals($qu[0], 'ux'); + $this->assertSame('ux', $qu[0]); $data = ['foo' => 'baz']; $this->assertSame($view, $view->withErrors(new MessageBag($data))); $foo = $view->errors->get('foo'); - $this->assertEquals($foo[0], 'baz'); + $this->assertSame('baz', $foo[0]); + $foo = $view->errors->getBag('default')->get('foo'); + $this->assertSame('baz', $foo[0]); + $this->assertSame($view, $view->withErrors(new MessageBag($data), 'login')); + $foo = $view->errors->getBag('login')->get('foo'); + $this->assertSame('baz', $foo[0]); } protected function getView($data = []) diff --git a/tests/View/fixtures/nested/basic.php b/tests/View/fixtures/nested/basic.php index 557db03de997c86a4a028e1ebd3a1ceb225be238..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100755 --- a/tests/View/fixtures/nested/basic.php +++ b/tests/View/fixtures/nested/basic.php @@ -1 +0,0 @@ -Hello World diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 3140dbea66a8e0895828c6e3f1e67b89b0ba911e..0000000000000000000000000000000000000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,34 +0,0 @@ -<?php - -/* -|-------------------------------------------------------------------------- -| Register The Composer Auto Loader -|-------------------------------------------------------------------------- -| -| Composer provides a convenient, automatically generated class loader -| for our application. We just need to utilize it! We'll require it -| into the script here so that we do not have to worry about the -| loading of any our classes "manually". Feels great to relax. -| -*/ - -require __DIR__.'/../vendor/autoload.php'; - -use Illuminate\Support\Carbon; - -/* -|-------------------------------------------------------------------------- -| Set The Default Timezone -|-------------------------------------------------------------------------- -| -| Here we will set the default timezone for PHP. PHP is notoriously mean -| if the timezone is not explicitly set. This will be used by each of -| the PHP date and date-time functions throughout the application. -| -*/ - -date_default_timezone_set('UTC'); - -Carbon::setTestNow(Carbon::now()); - -setlocale(LC_ALL, 'C.UTF-8');